All-in-one: Gym Gains Calculator (gym.php), Job Planner (jobs.php), Education Planner (education). Shared API key, shared stat/perk cache across pages.
// ==UserScript==
// @name Torn - AIO Planner
// @namespace http://torn.com/
// @version 2.8
// @description All-in-one: Gym Gains Calculator (gym.php), Job Planner (jobs.php), Education Planner (education). Shared API key, shared stat/perk cache across pages.
// @author iSatomi [3580191]
// @match https://www.torn.com/gym.php*
// @match https://www.torn.com/jobs.php*
// @match https://www.torn.com/page.php?sid=education*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @connect weav3r.dev
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// =========================================================================
// SHARED CORE — API key, gmFetch, cross-page stat/perk cache
// =========================================================================
const PAGE = location.href.includes('gym.php') ? 'gym'
: location.href.includes('jobs.php') ? 'job'
: location.href.includes('sid=education')? 'edu'
: null;
if (!PAGE) return;
// ── 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>`);
// =========================================================================
// ROUTE TO MODULE
// =========================================================================
if (PAGE === 'gym') gymModule();
if (PAGE === 'job') jobModule();
if (PAGE === 'edu') eduModule();
function gymModule() {
// ─────────────────────────────────────────────────────────────────────────
// 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 }
function checkGymAccess(gymId, currentGymId) {
const isSpecialist = SPECIALIST_IDS.has(gymId);
if (!isSpecialist) {
// Standard gyms unlock sequentially — accessible if position ≤ current gym position
const pos = STANDARD_UNLOCK_ORDER[gymId] ?? 99;
const curPos = STANDARD_UNLOCK_ORDER[currentGymId] ?? 0;
return pos <= curPos
? { state:'unlocked', reason:'', purchased:true }
: { state:'prereq', reason:'Not yet unlocked', purchased:false };
}
// Specialist gym
const req = SPECIALIST_REQS[gymId];
if (!req) return { state:'unlocked', reason:'', purchased:true };
// Check prereq gym is unlocked via sequential position
const prereqUnlocked = (STANDARD_UNLOCK_ORDER[req.prereqId] ?? 99) <= (STANDARD_UNLOCK_ORDER[currentGymId] ?? 0);
if (!prereqUnlocked) {
const prereqGym = GYM_BY_ID[req.prereqId];
return { state:'prereq', reason:`Unlock ${prereqGym?.name || 'required gym'} first`, purchased:false };
}
// Check stat ratio
const hasStats = allStats.strength > 0 || allStats.speed > 0 || allStats.defense > 0 || allStats.dexterity > 0;
if (hasStats) {
const ratioOk = req.check(allStats);
if (!ratioOk) return { state:'ratio', reason:req.desc, purchased:false };
}
// Specialist gyms need manual confirmation that user purchased the membership
const manualOwned = JSON.parse(_load('manualGymOwned','[]'));
const purchased = manualOwned.includes(gymId);
if (!purchased) return { state:'ratio', reason: req.drugLimited
? 'Ratio met · ≤50 Xanax+Ecstasy required · Tap "I own it" to confirm'
: 'Ratio met · Tap "I own it" to confirm membership', purchased:false };
if (req.drugLimited) return { state:'unlocked', reason:'Only if ≤50 Xanax and ≤50 Ecstasy taken lifetime', purchased:true };
return { state:'unlocked', reason:'', purchased:true };
}
// 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 };
// 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",
};
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:#555;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-tabs{display:flex;gap:6px;margin-bottom:10px}
.gg-tab{flex:1;padding:8px;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:background .15s}
.gg-tab.active{background:#18182a;border-color:#303058;color:#7a7acc}
.gg-tab:hover{background:#2a2a2a}
.gg-section{display:none}
.gg-section.active{display:block}
.gg-btn-row{display:flex;gap:6px;margin-top:12px;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 10px;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:#ff7a7a}
.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:10px;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:#555;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:auto 1fr 1fr;gap:0}
.gg-cmp-hdr{font-size:11px;font-weight:bold;color:#555;padding:6px 8px;background:#1a1a1a;border-bottom:1px solid #252525;text-align:center}
.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:12px;color:#666;padding:5px 8px;background:#1c1c1c;border-bottom:1px solid #222;white-space:nowrap}
.gg-cmp-val{font-size:12px;color:#bbb;padding:5px 8px;background:#1e1e1e;border-bottom:1px solid #222;text-align:right}
.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:10px;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-locked{font-size:10px;color:#4a3a1a;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-bestgym-note{font-size:10px;color:#446;margin-top:6px;line-height:1.6;padding:6px 8px;background:#141414;border-radius:3px}
.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}`;
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>
<div style="display:flex;align-items:center;gap:6px">
<button id="gg-global-settings" style="background:none;border:none;color:#555;font-size:16px;cursor:pointer;padding:2px 4px;-webkit-tap-highlight-color:transparent" title="API Key & Settings">⚙</button>
<span class="gg-toggle">▼</span>
</div>
</div>
<div class="gg-body">
<div class="gg-section" id="gg-settings-inputs">
<button class="gg-btn gg-btn-fill" id="gg-autofill" style="width:100%;margin-bottom:10px">⟳ Auto-fill from API</button>
<div class="gg-sec">API Key</div>
<div class="gg-field">
<label>Torn API Key <span style="font-size:10px;color:#555">— stored locally, auto-fills everything</span></label>
<div class="gg-api-row">
<input type="password" id="gg-apikey" placeholder="Paste your 16-character key…" autocomplete="off">
<button class="gg-api-btn" id="gg-apikey-save">Save</button>
</div>
<div style="margin-top:6px;padding:8px 10px;background:#141414;border:1px solid #252525;border-radius:4px;font-size:11px;line-height:1.7;color:#556">
<strong style="color:#668">Requires: Limited Access</strong> (for battle stats) or custom key:<br>
<span style="color:#4a6a4a">Normal Access (includes education, gym, perks, stats)</span><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 (Normal Access) →</a><br>
<span style="color:#444">Prices via <a href="https://weav3r.dev" target="_blank" style="color:#4a6a8a">weav3r.dev</a> (no key needed) · Data stored locally only</span>
</div>
</div>
<div class="gg-sec" style="margin-top:10px">Debug & Cache</div>
<div class="gg-sg" style="margin-bottom:6px">
<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-bottom:4px;display:none"></div>
</div>
<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>
<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-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 with API key to check specialist gym eligibility based on your stats.</div>
</div>
</div>
<div class="gg-field" style="margin:8px 0 6px">
<label style="font-weight:bold;color:#9a9acc;font-size:12px">Stat to Train</label>
<select id="gg-stat" style="font-size:15px;font-weight:bold">${statOpts}</select>
</div>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:0">
<button class="gg-tab active" id="gg-tab-daily" style="flex:1">Daily Grind</button>
<button id="gg-tab-daily-cfg" style="padding:7px 9px;border-radius:4px;border:1px solid #383838;background:#222;color:#556;font-size:13px;cursor:pointer;-webkit-tap-highlight-color:transparent" title="Daily settings">⚙</button>
<button class="gg-tab" id="gg-tab-jump" style="flex:1">Happy Jump</button>
<button id="gg-tab-jump-cfg" style="padding:7px 9px;border-radius:4px;border:1px solid #383838;background:#222;color:#556;font-size:13px;cursor:pointer;-webkit-tap-highlight-color:transparent" title="Jump settings">⚙</button>
</div>
<div class="gg-section active" id="gg-daily-inputs" style="padding:8px 0 4px;font-size:12px;color:#556;text-align:center">
Tap ⚙ to configure · then Calculate →
</div>
<div class="gg-section" id="gg-daily-cfg-inputs" style="margin-top:8px">
<div class="gg-sec">Daily Settings</div>
<div class="gg-sg">
<div class="gg-field"><label>Energy to Spend</label><input type="number" id="gg-energy" min="0"></div>
<div class="gg-field"><label>Subscriber</label>
<select id="gg-subscriber"><option value="no">No (100E)</option><option value="yes">Yes (150E)</option></select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Points Refill</label>
<select id="gg-dailyRefill"><option value="no">No</option><option value="yes">Yes ($1,725,000)</option></select>
</div>
<div class="gg-field"><label>Refill cost ($)</label><input type="number" id="gg-dailyRefillCost" min="0"></div>
</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"><div class="gg-val-msg" id="gg-val-stat">Enter your current stat total</div></div>
<div class="gg-field"><label>Stat Goal</label><input type="number" id="gg-statGoal" min="0"><div class="gg-val-msg" id="gg-val-goal">Goal must be higher than current stat</div></div>
</div>
<div class="gg-field"><label>Property Max Happy <span style="font-size:10px;color:#556">— your floor while training naturally</span></label><input type="number" id="gg-happy" min="0"><div class="gg-val-msg" id="gg-val-happy">Enter your property max happy</div></div>
<div class="gg-collapsible" id="gg-perks-panel">
<div class="gg-collapsible-header">Bonuses & Perks <span id="gg-perks-badge" style="font-size:10px;color:#4a7a4a;margin-left:4px"></span><span class="gg-collapsible-toggle">▼</span></div>
<div class="gg-collapsible-body">
<div style="font-size:11px;color:#556;margin-bottom:8px">Auto-filled from API. Adjust manually if needed.</div>
<div class="gg-sg">
<div class="gg-field"><label>Faction Gym Bonus</label>${perkSel('factionPerk')}</div>
<div class="gg-field"><label>Property Gym Bonus</label>${perkSel('propertyPerk')}</div>
<div class="gg-field"><label>Education (stat-specific)</label>${perkSel('eduStatPerk')}</div>
<div class="gg-field"><label>Education (all stats)</label>${perkSel('eduGenPerk')}</div>
<div class="gg-field"><label>Company / 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">0%</option><option value="20">20%</option></select>
</div>
<div class="gg-field"><label>Sports Sneakers</label>
<select id="gg-sportsSneakers"><option value="0">0%</option><option value="5">5%</option></select>
</div>
</div>
</div>
</div>
</div>
<div class="gg-section" id="gg-jump-inputs" style="padding:8px 0 4px;font-size:12px;color:#556;text-align:center">
Tap ⚙ to configure · then Calculate →
</div>
<div class="gg-section" id="gg-jump-cfg-inputs" style="margin-top:8px">
<div class="gg-sec">Happy Jump Setup</div>
<div class="gg-field">
<label>Happy at Jump Start <span id="gg-happy-badge" style="font-size:10px;color:#4a7a4a"></span></label>
<input type="number" id="gg-hjBaseHappy" min="0" placeholder="0 = use property max happy">
<div style="font-size:10px;color:#446;margin-top:3px">Your happy <em>before</em> eDVDs/FHC/ecstasy. 0 uses Property Max Happy.</div>
</div>
<div style="font-size:10px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase;margin:10px 0 4px">Energy Sources</div>
<div class="gg-sg">
<div class="gg-field"><label>Xanax to Stack</label>
<select id="gg-hjXanaxCount">
<option value="0">0 Xanax</option>
<option value="1">1 Xanax (~400E)</option><option value="2">2 Xanax (~650E)</option>
<option value="3">3 Xanax (~900E)</option><option value="4">4 Xanax (1000E)</option>
</select>
</div>
<div class="gg-field"><label>FHC (Feathery Hotel)</label>
<select id="gg-hjFHC">
${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' FHC (+'+i*FHC_HAPPY+' happy, '+(i*FHC_CD)+'h CD)'}</option>`).join('')}
</select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>LSD (50E each, no OD risk)</label>
<select id="gg-hjLSD">
${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' LSD (+'+(i*50)+'E)'}</option>`).join('')}
</select>
</div>
<div class="gg-field"><label>Points Refill after stack</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 (type)</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 E-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>Faction Voracity (extra CD hrs)</label>
<input type="number" id="gg-hjVoracity" min="0" max="24">
</div>
</div>
<div style="font-size:10px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase;margin:10px 0 4px">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"></div>
<div class="gg-field"><label>Ecstasy at Jump</label>
<select id="gg-hjEcstasy"><option value="yes">Yes</option><option value="no">No</option></select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>10★ Adult Novelties (×2 eDVD)</label>
<select id="gg-hjANJob"><option value="no">No</option><option value="yes">Yes</option></select>
</div>
<div class="gg-field"><label>FHC Happy boost</label>
<div style="font-size:11px;color:#4a7a4a;padding:8px 0">+${FHC_HAPPY} per FHC (auto)</div>
</div>
</div>
<div class="gg-sg">
<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 class="gg-field"><label>Number of Candies</label>
<input type="number" id="gg-hjCandies" min="0" max="48" placeholder="0">
</div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Faction 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 class="gg-field"><label>7★ Grocery Absorption (+10%)</label>
<select id="gg-hjCandyAbsorption"><option value="no">No</option><option value="yes">Yes (+10%)</option></select>
</div>
</div>
<div style="font-size:10px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase;margin:10px 0 4px">Costs <button class="gg-btn gg-btn-fill" id="gg-fetch-prices" style="font-size:10px;padding:4px 8px;margin-left:6px;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">
</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">
</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">
</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">
</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">
</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">
</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">
</div>
</div>
<div class="gg-collapsible" id="gg-live-prices-panel" style="margin-top:8px">
<div class="gg-collapsible-header">💰 Live Item Prices <span style="font-size:10px;color:#4a6a8a;margin-left:6px">all fetched prices</span><span class="gg-collapsible-toggle">▼</span></div>
<div class="gg-collapsible-body">
<div id="gg-live-prices" style="min-height:30px">
<div style="font-size:11px;color:#445">Click ⟳ Live Prices to load.</div>
</div>
</div>
</div>
<div class="gg-collapsible" id="gg-price-panel" style="margin-top:4px">
<div class="gg-collapsible-header">📊 Price Rankings <span style="font-size:10px;color:#4a6a8a;margin-left:6px">cheapest energy & happy per $</span><span class="gg-collapsible-toggle">▼</span></div>
<div class="gg-collapsible-body">
<div id="gg-price-breakdown" style="min-height:40px">
<div style="font-size:11px;color:#445">Click ⟳ Live Prices above to load live rankings.</div>
</div>
</div>
</div>
<div class="gg-collapsible" id="gg-od-panel">
<div class="gg-collapsible-header">OD Risk Settings <span class="gg-collapsible-toggle">▼</span></div>
<div class="gg-collapsible-body">
<div style="padding:4px 0 8px;font-size:11px;color:#665533">⚠ Community estimates — not officially confirmed.</div>
<div class="gg-sg">
<div class="gg-field"><label>Xanax base OD %</label><input type="number" id="gg-hjXanaxOD" min="0" max="100" step="0.1"></div>
<div class="gg-field"><label>Ecstasy base 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>7★ 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>
<div class="gg-btn-row">
<button class="gg-btn gg-btn-calc" id="gg-calc">Calculate →</button>
<button class="gg-btn gg-btn-compare" id="gg-compare">⚖ Compare</button>
<button class="gg-btn gg-btn-copy" id="gg-copy" style="display:none">📋 Copy</button>
</div>
<div style="font-size:10px;color:#444;margin-top:4px;text-align:right">Press Enter to calculate</div>
<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>
<div class="gg-collapsible" id="gg-logger-panel" style="margin-top:10px">
<div class="gg-collapsible-header">📓 Jump 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">
<div style="font-size:11px;color:#556;margin-bottom:8px;line-height:1.6">
Log each jump's actual gain. After 4–5 entries the script reverse-engineers your true gym dots using the formula — fixing accuracy permanently without needing the API gym endpoint.
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:6px 8px;background:#141414;border:1px solid #252525;border-radius:4px">
<span id="gg-autolog-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#5a9f5a;flex-shrink:0"></span>
<span id="gg-autolog-lbl" style="font-size:11px;color:#7abf7a;cursor:pointer;flex:1">Auto-log: ON — watches Train button for jump sessions</span>
<span style="font-size:10px;color:#445">tap to toggle</span>
</div>
<div class="gg-field">
<label>Actual Gain from Last Jump</label>
<input type="number" id="gg-log-gain" placeholder="e.g. 33161" min="0">
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Pre-jump Stat</label>
<input type="number" id="gg-log-stat" placeholder="stat before jump" min="0">
</div>
<div class="gg-field">
<label>Jump Happy (after eDVDs/xtc)</label>
<input type="number" id="gg-log-happy" placeholder="e.g. 35050" min="0">
</div>
</div>
<div style="font-size:10px;color:#446;margin-bottom:8px">Gym, stat, subscriber, happy floor and perks are taken from your current inputs above.</div>
<button class="gg-btn gg-btn-fill" id="gg-log-add" style="width:100%;margin-bottom:8px">+ Log This Jump</button>
<div id="gg-log-entries" style="margin-bottom:8px"></div>
<div id="gg-log-calibration" style="margin-bottom:8px"></div>
<div class="gg-sg" style="margin-top:4px">
<button class="gg-btn" id="gg-log-export" style="background:#1a1a2a;border-color:#303050;color:#7a7aaa;font-size:11px">⬇ Export CSV</button>
<button class="gg-btn" id="gg-log-clear" style="background:#1a1010;border-color:#301818;color:#aa5a5a;font-size:11px">🗑 Clear Log</button>
</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";
wrap.innerHTML = buildHTML();
const mt = document.querySelector('.content-wrapper') || document.body;
mt.insertBefore(wrap, mt.firstChild);
const q = sel => wrap.querySelector(sel);
// Gym dropdown rebuild after API load
let gymConfirmed = false; // tracks whether gym was auto-detected
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 reDetected = detectGymFromDOM();
if (reDetected) {
setGymSelect(sel, String(reDetected));
gymConfirmed = true;
if (el.gymBadge) el.gymBadge.textContent = "✓ page";
}
}
if (typeof updateDotsDisplay === 'function') updateDotsDisplay();
}
// Force select visual update (needed on TornPDA/webkit)
function setGymSelect(sel, idStr) {
sel.value = idStr;
// Explicitly select the matching option element
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"),
hjHappyBadge: q("#gg-happy-badge"),
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"),
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"),
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"),
};
// ─────────────────────────────────────────────────────────────────────────
// RESTORE & WIRE
// ─────────────────────────────────────────────────────────────────────────
const EL_MAP = {
// NOTE: gymId intentionally excluded — always detected from page DOM/API, never restored from storage
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",
};
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));
});
// Header toggle
wrap.querySelector(".gg-header").addEventListener("click", () => {
gg_save("ggCollapsed", wrap.classList.toggle("open") ? "no" : "yes");
});
if (gg_load("ggCollapsed") !== "yes") wrap.classList.add("open");
// API key
const savedKey = _load("apiKey","") || "";
el.apikey.value = savedKey;
el.apikey.placeholder = savedKey.length === 16 ? "Key saved ✓" : "Paste your 16-character key…";
el.apikeyBtn.addEventListener("click", () => {
const k = el.apikey.value.trim();
if (k.length !== 16) { showStatus("warn", `⚠ API key must be 16 characters (got ${k.length}).`); return; }
gg_save("apiKey", k);
el.apikey.placeholder = "Key saved ✓";
showStatus("ok", "✓ API key saved. Click Auto-fill to load your data.");
loadGymsFromAPI(k); // refresh gym list with new key
});
// Mode tabs
let mode = gg_load("calcMode") || "daily";
function setMode(m) {
mode = m;
if (m === "daily" || m === "happyjump") gg_save("calcMode", m);
q("#gg-tab-daily")?.classList.toggle("active", m === "daily");
q("#gg-tab-jump")?.classList.toggle("active", m === "happyjump");
const hlt = (id, on) => { const b = q("#"+id); if (!b) return; b.style.color = on ? "#7a7acc" : "#556"; b.style.borderColor = on ? "#303058" : "#383838"; };
hlt("gg-tab-daily-cfg", m === "daily-cfg");
hlt("gg-tab-jump-cfg", m === "jump-cfg");
const gs = q("#gg-global-settings"); if (gs) gs.style.color = m === "settings" ? "#7a7acc" : "#555";
const MAP = { "daily":"gg-daily-inputs", "daily-cfg":"gg-daily-cfg-inputs",
"happyjump":"gg-jump-inputs", "jump-cfg":"gg-jump-cfg-inputs", "settings":"gg-settings-inputs" };
Object.entries(MAP).forEach(([k,id]) => q("#"+id)?.classList.toggle("active", m===k));
}
q("#gg-tab-daily")?.addEventListener("click", () => setMode("daily"));
q("#gg-tab-jump")?.addEventListener("click", () => setMode("happyjump"));
q("#gg-tab-daily-cfg")?.addEventListener("click", () => setMode(mode === "daily-cfg" ? "daily" : "daily-cfg"));
q("#gg-tab-jump-cfg")?.addEventListener("click", () => setMode(mode === "jump-cfg" ? "happyjump" : "jump-cfg"));
q("#gg-global-settings")?.addEventListener("click", e => {
e.stopPropagation();
setMode(mode === "settings" ? (gg_load("calcMode") || "daily") : "settings");
});
setMode(mode);
// Collapsibles — all use optional chaining to prevent crash if panel hidden/missing
const wireCollapsible = id => {
q("#"+id)?.querySelector(".gg-collapsible-header")
?.addEventListener("click", () => q("#"+id)?.classList.toggle("open"));
};
["gg-perks-panel","gg-od-panel","gg-price-panel","gg-live-prices-panel",
"gg-logger-panel","gg-export-panel"].forEach(wireCollapsible);
// Load cached prices on startup (so breakdown shows immediately)
(function loadCachedPrices() {
const cached = _load(PRICE_CACHE_KEY, null);
if (!cached) return;
try {
const { ts, data } = JSON.parse(cached);
const hasCans = data?.cans && Object.keys(data.cans).length > 0;
if (Date.now() - ts < PRICE_CACHE_TTL && hasCans) {
applyPriceData(data, gg_load("subscriber") || "no", true);
}
} catch(_) {}
})();
// When candy type changes, auto-fill the cost input
q("#gg-hjCandyType")?.addEventListener("change", () => {
const idx = parseInt(q("#gg-hjCandyType").value);
if (idx > 0) {
const candy = CANDY_TYPES[idx - 1];
const 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();
});
// When can type changes, auto-fill the cost input from fetched prices
q("#gg-hjCanType")?.addEventListener("change", () => {
const idx = parseInt(q("#gg-hjCanType").value);
if (idx > 0) {
const can = CAN_TYPES[idx - 1];
const 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();
});
// Rebuild breakdown when subscriber or faction perk changes
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(); maybeRecalc(); });
el.autofill.addEventListener("click", autofill);
el.fetchPricesBtn.addEventListener("click", () => {
const hasCache = !!_load(PRICE_CACHE_KEY, null);
fetchPrices(hasCache); // force refresh if cache exists
});
el.calc.addEventListener("click", calculate);
el.compare.addEventListener("click", () => { try { calculateCompare(); } catch(e) { showStatus("warn","✗ "+e.message); } });
el.copy.addEventListener("click", copyResults);
// Enter key → calculate
wrap.addEventListener("keydown", e => { if (e.key === "Enter" && document.activeElement?.tagName !== "BUTTON") calculate(); });
// Auto-recalc when key inputs change (only if results are visible)
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();
});
});
// Best gym panel toggle
q("#gg-bestgym-panel")?.querySelector(".gg-bestgym-header")
?.addEventListener("click", () => { q("#gg-bestgym-panel").classList.toggle("open"); updateBestGymPanel(); });
// ─────────────────────────────────────────────────────────────────────────
// GYM DOTS DISPLAY
// ─────────────────────────────────────────────────────────────────────────
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 || (dailyVisible && mode === "happyjump")) calculateHappyJump();
else calculateDaily();
} 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,
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/max happy
if (data.energy?.maximum) {
out.energyCap = data.energy.maximum;
out.subscriber = data.energy.maximum >= 150 ? "yes" : "no";
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":""}`);
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;
}
}
}
// 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);
renderLivePrices();
}
// ── Live Prices Panel ────────────────────────────────────────────────────
// Shows can + candy prices (the items without dedicated badge displays)
function renderLivePrices() {
const panel = q("#gg-live-prices");
if (!panel) return;
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 fmtP = p => p >= 1e6 ? `$${(p/1e6).toFixed(2)}m` : `$${Math.round(p/1000)}k`;
const row = (name, stat, price, extra) => {
const priceStr = price ? fmtP(price) : `<span style="color:#445">—</span>`;
const effStr = price && extra ? extra : "";
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #1a1a2a">
<span style="font-size:11px;color:#8899aa;flex:1">${name}</span>
<span style="font-size:10px;color:#5588aa;margin:0 8px;white-space:nowrap">${stat}</span>
<span style="font-size:12px;color:#aabbcc;text-align:right;min-width:60px">${priceStr}</span>
${effStr ? `<span style="font-size:10px;color:#445;text-align:right;margin-left:6px;min-width:40px">${effStr}</span>` : ""}
</div>`;
};
let html = "";
// Energy Cans
html += `<div style="font-size:9px;font-weight:bold;color:#4a6a8a;text-transform:uppercase;letter-spacing:.06em;margin:4px 0 4px;padding-bottom:3px;border-bottom:1px solid #222">
⚡ Energy Cans${canFacPct > 0 ? ` <span style="color:#445;font-weight:normal">(+${Math.round(canFacPct*100)}% faction)</span>` : ""}
<span style="float:right;color:#334;font-weight:normal">stat · price · $/E</span>
</div>`;
CAN_TYPES.forEach(c => {
const effectiveE = canFacPct > 0 ? Math.round(c.e * (1 + canFacPct)) : c.e;
const p = canPrices[c.label] || 0;
const effStr = p ? Math.round(p / effectiveE / 1000) + "k/E" : "";
html += row(c.label, `+${effectiveE}E · 2h CD`, p, effStr);
});
// Candy
html += `<div style="font-size:9px;font-weight:bold;color:#8a6a4a;text-transform:uppercase;letter-spacing:.06em;margin:10px 0 4px;padding-bottom:3px;border-bottom:1px solid #222">
🍬 Candy${candyMult > 1 ? ` <span style="color:#445;font-weight:normal">(×${candyMult.toFixed(2)} voracity)</span>` : ""}
<span style="float:right;color:#334;font-weight:normal">stat · price · $/happy</span>
</div>`;
CANDY_TYPES.forEach(c => {
const effectiveH = Math.round(c.happy * candyMult);
const p = candyPrices[c.label] || 0;
const effStr = p ? Math.round(p / effectiveH / 1000) + "k/★" : "";
html += row(c.label, `+${effectiveH} happy · 30m CD`, p, effStr);
});
// Cache age
try {
const { ts } = JSON.parse(_load(PRICE_CACHE_KEY, "{}"));
if (ts) {
const ageMin = Math.round((Date.now()-ts)/60000);
const ageStr = ageMin < 1 ? "just now" : `${ageMin}m ago`;
html += `<div style="font-size:9px;color:#334;text-align:right;margin-top:6px;padding-top:4px;border-top:1px solid #1a1a2a">updated ${ageStr} · 1h cache · click ⟳ to refresh</div>`;
} else {
html += `<div style="font-size:9px;color:#445;margin-top:6px">Click ⟳ Live Prices to load prices</div>`;
}
} catch(_) {}
panel.innerHTML = html;
// Auto-open the panel so the user sees the results
q("#gg-live-prices-panel")?.classList.add("open");
}
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; // energy can faction %
const candyVorPct = gi(el.hjCandyVoracity) / 100; // candy voracity %
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;
// Build rows: { name, e_raw, e_eff, price, per_e, cd, notes }
const rows = [];
// Xanax — 250E, 7h avg CD
if (xanPrice > 0) rows.push({ name:"Xanax", e:250, price:xanPrice, cd:"6–8h", notes:"Drug. OD risk. ~250E" });
// LSD — 50E, no OD
if (lsdPrice > 0) rows.push({ name:"LSD", e:50, price:lsdPrice, cd:"~3h", notes:"No OD risk. 50E" });
// FHC — refills to cap + 500 happy, 6h CD
if (fhcPrice > 0) rows.push({ name:"FHC", e:cap, price:fhcPrice, cd:"6h", notes:`Refills to ${cap}E + 500 happy` });
// Points refill — always 1,725,000
rows.push({ name:"Points Refill", e:cap, price:1725000, cd:"—", notes:`Refills to ${cap}E. Daily via 25pts` });
// Energy cans
CAN_TYPES.forEach(can => {
const price = canPrices[can.label];
if (!price) return;
const eEff = Math.round(can.e * (1 + canFacPct));
rows.push({ name: can.label, e:eEff, price, cd:"2h/can", notes:`${can.e}E base${canFacPct>0?` + ${canFacPct*100}% faction = ${eEff}E`:""}` });
});
if (!rows.length) {
const noKey = !(_load("apiKey","")||"").trim();
panel.innerHTML = `<div style='font-size:11px;color:#445'>${noKey
? "Add an API key and click ⟳ Live Prices to load rankings."
: "Click ⟳ Live Prices to load live rankings."}</div>`;
return;
}
// Sort by $/E (cheapest first)
rows.forEach(r => r.per_e = r.price / r.e);
rows.sort((a, b) => a.per_e - b.per_e);
const best = rows[0].per_e;
let html = `<div style="font-size:11px;color:#4a5a6a;margin-bottom:6px">
$/E calculated live. ${canFacPct>0?`Faction E-can +${canFacPct*100}% applied.`:""} ${cap}E cap (${cap===150?"Subscriber":"Non-sub"}).
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr auto auto auto;gap:2px 8px;align-items:baseline">
<div style="font-size:10px;color:#445;text-transform:uppercase">Item</div>
<div style="font-size:10px;color:#445;text-align:right">Energy</div>
<div style="font-size:10px;color:#445;text-align:right">$/E</div>
<div style="font-size:10px;color:#445;text-align:right">CD</div>`;
rows.forEach((r, i) => {
const isBest = i === 0;
const ratio = r.per_e / best;
const color = ratio < 1.15 ? "#7abf7a" : ratio < 1.5 ? "#bf9f5a" : "#778";
const star = isBest ? " ★" : "";
html += `
<div style="font-size:12px;color:${color};font-weight:${isBest?'bold':'normal'}" title="${r.notes}">${r.name}${star}</div>
<div style="font-size:11px;color:#778;text-align:right">${r.e}E</div>
<div style="font-size:12px;color:${color};text-align:right;font-weight:${isBest?'bold':'normal'}">${fmt(Math.round(r.per_e))}</div>
<div style="font-size:11px;color:#556;text-align:right">${r.cd}</div>`;
});
html += `</div>`;
// ── Section 1b: Candy $/happy ranking (separate from energy) ─────────
const candyRows = [];
CANDY_TYPES.forEach(c => {
const price = candyPrices[c.label];
if (!price) return;
const happyEff = Math.round(c.happy * candyMult);
candyRows.push({ name: c.label, happy: happyEff, price, per_h: price / happyEff,
baseHappy: c.happy, maxTotal: 48 * happyEff });
});
if (candyRows.length > 0) {
candyRows.sort((a, b) => a.per_h - b.per_h);
const bestHappy = candyRows[0].per_h;
const modNote = [
candyVorPct > 0 ? `+${Math.round(candyVorPct*100)}% Voracity` : '',
candyAbsPct > 0 ? '+10% Absorption' : '',
].filter(Boolean).join(', ');
html += `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #252535">
<div style="font-size:10px;color:#445;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">
Candy $/Happy${modNote ? ` · ${modNote}` : ""} · 30min CD each, 48 max
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 50px 56px 56px;gap:1px 6px;align-items:center">
<div style="font-size:9px;color:#334;text-transform:uppercase;padding-bottom:3px;border-bottom:1px solid #252535">Candy</div>
<div style="font-size:9px;color:#334;text-transform:uppercase;text-align:center;padding-bottom:3px;border-bottom:1px solid #252535">Happy</div>
<div style="font-size:9px;color:#334;text-transform:uppercase;text-align:right;padding-bottom:3px;border-bottom:1px solid #252535">$/Happy</div>
<div style="font-size:9px;color:#334;text-transform:uppercase;text-align:right;padding-bottom:3px;border-bottom:1px solid #252535">48× max</div>`;
candyRows.forEach((r, i) => {
const ratio = r.per_h / bestHappy;
const isBest = i === 0;
const color = isBest ? "#7abf7a" : ratio < 1.2 ? "#9abf6a" : ratio < 2 ? "#bf9f5a" : "#5a5a6a";
const badge = isBest ? `<span style="font-size:9px;background:#1a3a1a;border:1px solid #2a5a2a;border-radius:3px;padding:0 4px;margin-left:4px;color:#7abf7a">BEST</span>` : "";
const maxHappy = fmt(r.maxTotal);
html += `
<div style="font-size:12px;color:${color};font-weight:${isBest?'bold':'normal'};padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${r.name}${badge}</div>
<div style="font-size:11px;color:#556;text-align:center;padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${r.happy}</div>
<div style="font-size:${isBest?'13':'12'}px;color:${color};font-weight:${isBest?'bold':'normal'};text-align:right;padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${fmt(Math.round(r.per_h))}</div>
<div style="font-size:11px;color:#3a4a5a;text-align:right;padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${maxHappy}</div>`;
});
html += `</div>`;
// Cost to get X happy from candy
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid #252535">
<div style="font-size:10px;color:#445;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Cost for 49 candies (common jump amount)</div>`;
candyRows.forEach((r, i) => {
const totalCost = 49 * r.price;
const totalHappy = 49 * r.happy;
const isBest = i === 0;
const color = isBest ? "#7abf7a" : "#778";
const costStr = totalCost >= 1e6 ? `$${(totalCost/1e6).toFixed(2)}m` : `$${(totalCost/1000).toFixed(0)}k`;
html += `<div style="display:flex;justify-content:space-between;padding:2px 0;${i>0?'border-top:1px solid #1a1a28':''}">
<span style="font-size:11px;color:${color};font-weight:${isBest?'bold':'normal'}">${r.name} ×49</span>
<span style="font-size:11px;color:${color};font-weight:${isBest?'bold':'normal'}">${costStr} <span style="font-size:10px;color:#445">+${fmt(totalHappy)} happy</span></span>
</div>`;
});
html += `</div></div>`;
}
// FHC note about happy bonus
if (fhcPrice > 0) {
const fhcRow = rows.find(r => r.name === "FHC");
if (fhcRow) {
const effPriceWithHappy = fhcPrice; // happy value is hard to quantify in $
html += `<div style="font-size:10px;color:#4a6a8a;margin-top:6px;padding-top:6px;border-top:1px solid #252535">
★ FHC also gives 500 happy — this can significantly boost gains if your happy is below max.
</div>`;
}
}
// "To get 150E" breakdown for subscriber perspective
const targetE = cap;
html += `<div style="font-size:10px;font-weight:bold;color:#445;text-transform:uppercase;letter-spacing:.06em;margin-top:10px;padding-top:6px;border-top:1px solid #252535">
Cost to get ${targetE}E
</div>`;
rows.forEach(r => {
if (!r.price) return;
const canCount = Math.ceil(targetE / r.e);
const totalCost = canCount * r.price;
const totalCD = r.name === "Points Refill" ? "—" :
r.name === "FHC" ? `${Math.ceil(targetE / r.e) * (r.name==="FHC"?6:2)}h CD` :
r.cd !== "—" ? `${canCount * 2}h CD` : "—";
html += `<div style="display:flex;justify-content:space-between;font-size:11px;color:#778;padding:2px 0">
<span>${r.name}${canCount>1?` ×${canCount}`:""}</span>
<span style="color:#aab">$${fmt(totalCost)} <span style="color:#445;font-size:10px">${totalCD}</span></span>
</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();
showStatus("ok", "⟳ Fetching data…");
const gymLoadPromise = loadGymsFromAPI(apiKey);
const apiPromise = apiKey.length===16 ? fetchFromAPI(stat) : Promise.resolve(null);
const domStat = tryAutoFillStat();
const domGymId = detectGymFromDOM();
Promise.all([gymLoadPromise, apiPromise]).then(([_, api]) => {
if (api?.error) { showStatus("warn", `⚠ ${api.error}`); 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
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.currentHappy != null) {
// Set hjBaseHappy to current happy for this session only — don't persist
el.hjBaseHappy.value = api.currentHappy;
el.hjHappyBadge.textContent = `✓ ${api.currentHappy.toLocaleString()} current`;
}
// 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"; q("#gg-perks-panel").classList.add("open"); }
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);
if (apiKey.length===16) fetchPrices();
});
}
// ─────────────────────────────────────────────────────────────────────────
// STARTUP — detect gym from page DOM immediately (sync), stats after DOM settles
// ─────────────────────────────────────────────────────────────────────────
// Detect gym from page right away
(function detectGymOnLoad() {
el.gym.value = "";
const domGymId = detectGymFromDOM();
if (domGymId) {
setGymSelect(el.gym, String(domGymId));
el.gymBadge.textContent = "✓ page";
gymConfirmed = true;
}
})();
// After 800ms for React/SPA to settle, re-detect and fill stats
setTimeout(() => {
const domGymId = detectGymFromDOM();
if (domGymId) {
setGymSelect(el.gym, String(domGymId));
el.gymBadge.textContent = "✓ page";
gymConfirmed = true;
}
tryAutoFillStat();
updateDotsDisplay();
validateInputs();
updateBestGymPanel();
}, 800);
// Third attempt at 2.5s for slow-loading SPAs (TornPDA)
setTimeout(() => {
if (!gymConfirmed) {
const domGymId = detectGymFromDOM();
if (domGymId) {
setGymSelect(el.gym, String(domGymId));
el.gymBadge.textContent = "\u2713 page";
gymConfirmed = true;
updateDotsDisplay();
updateBestGymPanel();
}
}
}, 2500);
// Third attempt at 2.5s for slow-loading SPAs (TornPDA)
setTimeout(() => {
if (!gymConfirmed) {
const domGymId = detectGymFromDOM();
if (domGymId) {
setGymSelect(el.gym, String(domGymId));
el.gymBadge.textContent = "✓ page";
gymConfirmed = true;
updateDotsDisplay();
updateBestGymPanel();
}
}
}, 2500);
// Load cached gym dots from API (if saved), refresh dots display
setTimeout(() => {
const k = (_load("apiKey","")||"").trim();
if (k.length === 16) loadGymsFromAPI(k).then(() => {
updateDotsDisplay();
updateBestGymPanel();
});
}, 100);
// Restore last-updated label
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`;
}
// ─────────────────────────────────────────────────────────────────────────
// AUTO GYM SWITCHER
// Intercepts Torn's Train buttons. If not in the best gym for the stat
// being trained, it clicks Torn's own Switch button instead, shows a banner,
// and lets the user click Train again.
// ToS compliant: one user interaction → one action (gym switch), no background requests.
// ─────────────────────────────────────────────────────────────────────────
// Switcher toggle — saved in storage
let switcherEnabled = _load('autoSwitchEnabled', true);
// Add toggle to our widget header area
const switcherToggle = document.createElement('div');
switcherToggle.style.cssText = 'font-size:10px;color:#556;padding:4px 12px 0;display:flex;align-items:center;gap:6px;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none';
switcherToggle.innerHTML = `
<span id="gg-switcher-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${switcherEnabled?'#5a9f5a':'#555'};flex-shrink:0"></span>
<span id="gg-switcher-lbl" style="color:${switcherEnabled?'#7abf7a':'#555'}">Auto gym switcher: ${switcherEnabled?'ON':'OFF'}</span>`;
wrap.querySelector('.gg-body').prepend(switcherToggle);
switcherToggle.addEventListener('click', () => {
switcherEnabled = !switcherEnabled;
_save('autoSwitchEnabled', switcherEnabled);
switcherToggle.querySelector('#gg-switcher-dot').style.background = switcherEnabled ? '#5a9f5a' : '#555';
const lbl = switcherToggle.querySelector('#gg-switcher-lbl');
lbl.style.color = switcherEnabled ? '#7abf7a' : '#555';
lbl.textContent = `Auto gym switcher: ${switcherEnabled ? 'ON' : 'OFF'}`;
if (!switcherEnabled) hideSwitchBanner();
});
// Banner shown when gym is switched
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) {
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;
}
// ─────────────────────────────────────────────────────────────────────────
// AUTO JUMP LOGGER
// Watches Train button clicks and the post-train result DOM mutation to
// capture: pre-jump stat, jump happy, total gain, trains, and gym context.
// Groups consecutive trains into a session; auto-logs when idle >5 min.
// ─────────────────────────────────────────────────────────────────────────
// Parse stat value from the gym card — Torn shows e.g. "352,245" next to stat icon
function readStatFromPage(statKey) {
// Map our stat key to label text shown on page
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);
// Scan all text for "STR 33,401" pattern
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 t = node.textContent.trim();
if (t === abbrev) {
// Value is usually in the next sibling text node or nearby element
const sib = node.parentElement?.nextElementSibling;
if (sib) {
const v = parseFloat(sib.textContent.replace(/,/g,''));
if (v > 0) return v;
}
// Or in the same parent as a number following the label
const parent = node.parentElement?.parentElement;
if (parent) {
const nums = parent.textContent.match(/[\d,]{3,}/g);
if (nums) {
const v = parseFloat(nums[0].replace(/,/g,''));
if (v > 0) return v;
}
}
}
// Also handle "STR\n33,401" style (label + value in same element)
if (new RegExp(`^${abbrev}\\s+([\\d,]+)$`).test(t)) {
const m = t.match(/[\d,]+$/);
if (m) return parseFloat(m[0].replace(/,/g,''));
}
}
return null;
}
// Read current energy from page ("You have X/Y energy")
// Read current happy from page
function readHappyFromPage() {
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 t = node.textContent.trim();
// Torn shows happy as a number in the sidebar or top bar
if (/^\d{2,6}$/.test(t.replace(/,/g,''))) {
const parent = node.parentElement;
const ctx = (parent?.closest('[class*="happy"], [id*="happy"]') ||
parent?.previousElementSibling?.textContent?.toLowerCase().includes('happy') ||
parent?.parentElement?.textContent?.toLowerCase().includes('happiness'));
if (ctx) {
const v = parseInt(t.replace(/,/g,''));
if (v > 0 && v < 100000) return v;
}
}
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// JUMP SESSION TRACKING
// Each jump is ONE train result (e.g. 999 energy = one click).
// Possibly followed by ONE refill train (~150E) after returning from Points.
// We track a pending session: log immediately on the jump train, then
// watch for a refill train within a short window to add to the total.
// Page unload between the two is fine — the stack entry is already saved.
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// AUTO JUMP LOGGER — per-event logging
// Each train result is logged independently using the actual energy detected
// from Torn's result text ("You used 1000 energy...").
// No merge logic, no session state — simple and reliable.
// The calibration formula uses actualEnergy directly for each entry.
// ─────────────────────────────────────────────────────────────────────────
function logTrainEvent(gain, newStat, statKey, energyUsed) {
if (!gain || !statKey) return;
if (!window._ggAutoLogEnabled) return;
// Filter out daily grind: only log if energy used is well above natural max (150E)
// 1000E jump: gain ~31k-36k | 150E refill: gain ~2k-5k
// Both are valid jump-cycle events — log both, just mark them differently
const isMainJump = energyUsed >= 500; // 1000E Xanax stack
const isRefillJump = energyUsed >= 100 && energyUsed <= 499; // 150E refill
if (!isMainJump && !isRefillJump) return; // daily grind — skip
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;
const preStat = Math.round(newStat - gain);
if (preStat <= 0) return;
const entry = {
ts: Date.now(),
stat,
gymId: gym?.id,
gymName: gym?.name,
gymDots,
preStat,
jumpHappy: readHappyFromPage() || gf(el.happy) || 5025,
actualGain: Math.round(gain),
energyUsed, // actual energy from result text
stackTrains: isMainJump ? 1 : 0, // for CSV compat
refillTrains: isRefillJump ? 1 : 0, // for CSV compat
propHappy: gf(el.happy) || 5025,
ePerTrain: gym?.energy || 10,
bonus: calcBonus(stat),
autoLogged: 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) < 30000 &&
Math.abs(e.actualGain - entry.actualGain) < 100
);
if (isDupe) return;
entries.push(entry);
logSave(entries);
renderLogger();
const typeLabel = isMainJump ? '1000E jump' : '150E refill';
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(';');
banner.textContent = `📓 Logged ${typeLabel}: +${fmt(entry.actualGain)} ${STAT_LABELS[stat]}`;
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) {
// For characterData mutations the target IS the text node
const nodes = mut.type === 'characterData'
? [mut.target]
: [...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;
// Only accept result text from the gym training area.
// Torn's event log, notifications, and other panels also contain
// "You used X energy..." text — we must ignore those.
// The gym result appears inside #gymroot or the main content wrapper,
// NOT inside a log/event/notification overlay panel.
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 < 5) continue;
// Extra guard: the result text must contain our key trigger phrase.
// This rules out partial matches from log snippets.
const hasResult = /increasing it by|you (?:have )?gained\s+[\d,]+\s+(?:strength|speed|defense|dexterity)/i.test(text);
if (!hasResult) continue;
let gain = null, newStat = null, statKey = null, energyUsed = null;
// Pattern 1 (full): "You used 1000 energy...increasing it by 31887 to 453315"
// Captures energy used AND gain AND new stat
const mFull = text.match(/you used\s+([\d,]+)\s+energy[^.]*?increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
if (mFull) {
energyUsed = parseInt(mFull[1].replace(/,/g,''));
gain = parseFloat(mFull[2].replace(/,/g,''));
newStat = Math.round(parseFloat(mFull[3].replace(/,/g,'')));
}
// Pattern 2 (short): "increasing it by 31887.00 to 453315.00"
if (!gain) {
const mShort = text.match(/increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
if (mShort) {
gain = parseFloat(mShort[1].replace(/,/g,''));
newStat = Math.round(parseFloat(mShort[2].replace(/,/g,'')));
// Try to also find energy in the same text block
const mE = text.match(/you used\s+([\d,]+)\s+energy/i);
if (mE) energyUsed = parseInt(mE[1].replace(/,/g,''));
}
}
// Pattern 3: "You have gained 35158.00 dexterity" / "You gained 35158 strength"
// This is a per-train result from Torn (not a session total).
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 = Math.round(readStatFromPage(statKey) || 0);
// Infer energy from gain magnitude
energyUsed = gain > 10000 ? 1000 : 150;
}
}
if (!gain) continue;
// Try to get stat from "training your dexterity" in the text
if (!statKey) {
const mStat = text.match(/training (?:your\s+)?(strength|speed|defense|dexterity)/i);
if (mStat) statKey = STAT_KEYS[mStat[1].toLowerCase()];
}
// Fallback to context or last clicked
if (!statKey) {
const container = node.parentElement;
if (container) {
const ctxt = container.textContent.toLowerCase();
for (const [k, sk] of Object.entries(STAT_KEYS)) {
if (ctxt.includes(k) || ctxt.includes(STAT_LABELS[k].toLowerCase())) {
statKey = sk; break;
}
}
}
if (!statKey && lastTrainedStat) statKey = lastTrainedStat;
}
// If energyUsed still unknown, infer from gain magnitude
if (!energyUsed && gain) {
energyUsed = gain > 10000 ? 1000 : 150;
}
if (gain && statKey && energyUsed) {
if (!newStat) newStat = Math.round(readStatFromPage(statKey) || 0);
logTrainEvent(gain, newStat, statKey, energyUsed);
}
}
}
});
resultObserver.observe(document.body, { childList: true, subtree: true, characterData: true, characterDataOldValue: false });
// 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
function handleTrainClick(e, btn) {
// Snapshot stat AND happy at the moment of click — before the train processes
// and before happy regen ticks. This is the only reliable moment to read happy.
const statKey = getStatForTrainButton(btn);
if (statKey) lastTrainedStat = statKey;
lastTrainedHappy = readHappyFromPage(); // snapshot now, before result fires
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() {
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
if (wrap.contains(btn)) continue;
if (hookedButtons.has(btn)) continue;
const t = btn.textContent.trim().toUpperCase();
if (t !== '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;
return (stat*lnPart + 8*Math.pow(H,1.05) + (1-Math.pow(H/99999,2))*consts.A + consts.B)
* (1/200000) * dots * energy * bonus;
}
// 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;
// Initial energy block
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;
// Natural regen
const natEPerHr = NATURAL_E[el.subscriber.value] || NATURAL_E.no;
const natEPerDay = natEPerHr * 24;
const natTrains = Math.floor(natEPerDay / ePerTrain);
const wastedE = natEPerDay - natTrains * ePerTrain;
const dayBlock = simulateBlock(dots, initBlock.finalStat, 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 total</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)} — this is your property max happy floor`);
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)} (random variance ±${fmtD(errSingle,2)})`);
// ── Current energy block ──────────────────────────────────────────────
if (numTrains > 0) {
html += rsec(`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 spending this energy`);
}
// ── Natural regen ─────────────────────────────────────────────────────
html += rsec("Natural Energy Regen (No Refill)");
html += row("Energy/day", `${natEPerDay}E — you regen ${natEPerHr}E/hr × 24 hrs`);
html += row("Trains/day", `${natTrains} trains (${ePerTrain}E each)`);
if (wastedE > 0) html += row("Wasted energy", `${wastedE}E/day is lost — your regen doesn't divide evenly into ${ePerTrain}E trains`, "a");
html += row("Gain/day", `+${fmt(dayBlock.totalGain)} ${STAT_LABELS[stat]} from natural regen only`, "hi");
// ── Daily refill ──────────────────────────────────────────────────────
if (useRefill) {
html += rsec("Points Refill (Extra $1,725,000/day)");
html += row("Energy bought", `${refillE}E → ${refillTrains} more trains`);
html += row("Extra gain", `+${fmt(refillBlock.totalGain)} ${STAT_LABELS[stat]} from refill`, "hi");
html += row("Cost", `$${fmt(refillCost)}/day from Points building`, "b");
}
// ── Total ─────────────────────────────────────────────────────────────
html += rsec("Total Per Day");
html += row("Gain/day", `+${fmt(totalDailyGain)} ${STAT_LABELS[stat]}${useRefill?" (regen + refill)":""}`, "hi");
if (useRefill) html += row("Cost/day", `$${fmt(dailyCostPerDay)} for the Points refill`, "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 of training like this`, "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, propHappy + fhcHappyBoost * 0.5, 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, propHappy + fhcHappyBoost * 0.5, 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, lsdCount, canCount, canType,
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 gains ─────────────────────────────────────────────────
const eDaily = gi(el.energy) || energyCap;
const dailyTrains = Math.floor(eDaily / ePerTrain);
const gainDaily = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, dailyTrains).totalGain;
const refillCost = el.dailyRefill.value === "yes" ? gf(el.dailyRefillCost) : 0;
const cycleDaily = energyCap / natEPerHr;
const weeklyDaily = gainDaily * (24 * 7 / cycleDaily);
// ── Happy Jump gains ──────────────────────────────────────────────────
const gainStack = simulateBlock(dots, statNow, jumpHappy, bonus, ePerTrain, consts, stackTrains).totalGain;
const gainFHC = fhcCount > 0 ? simulateBlock(dots, statNow+gainStack, propHappy+fhcHappyBoost*0.5, 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);
// ── Goal projections ──────────────────────────────────────────────────
let dJumps=0, dCur=statNow;
while (dCur<statGoal && dJumps<MAX_ITER) {
dCur += simulateBlock(dots,dCur,propHappy,bonus,ePerTrain,consts,dailyTrains).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,propHappy+fhcHappyBoost*0.5,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++;
}
const daysDaily = dJumps * (cycleDaily / 24);
const daysJump = jJumps * (cycleAvg / 24);
// ── Build comparison table ────────────────────────────────────────────
const dailyWins = (metric) => metric === "gain" ? gainDaily >= gainJump
: metric === "weekly" ? weeklyDaily >= weeklyJump
: daysDaily <= daysJump;
const val = (v, win) => `<div class="gg-cmp-val${win?" win":" lose"}">${v}</div>`;
let html = `<div class="gg-cmp">
<div class="gg-cmp-title">⚖ Daily Grind vs Happy Jump — ${statLabel} at ${gym.name}</div>
<div class="gg-cmp-grid">
<div class="gg-cmp-hdr"></div>
<div class="gg-cmp-hdr daily">Daily Grind</div>
<div class="gg-cmp-hdr jump">Happy Jump</div>`;
const rows_ = [
["Gain / session", `+${fmtD(gainDaily,1)}`, `+${fmtD(gainJump,1)}`, "gain"],
["Gain / week", `+${fmt(weeklyDaily)}`, `+${fmt(weeklyJump)}`, "weekly"],
["Cycle time", `${fmtD(cycleDaily,1)}h`, `${fmtD(cycleAvg,1)}h`, null],
["Cost / session", `$${fmt(refillCost||0)}`, `$${fmt(costTotal)}`, null],
["Sessions to goal",`${dJumps>=MAX_ITER?"∞":dJumps}`, `${jJumps>=MAX_ITER?"∞":jJumps}`, null],
["Days to goal", `${fmtD(daysDaily,1)}d`, `${fmtD(daysJump,1)}d`, "days"],
];
for (const [label, dv, jv, metric] of rows_) {
const dWins = metric ? dailyWins(metric) : null;
html += `<div class="gg-cmp-label">${label}</div>
${val(dv, dWins === true)} ${val(jv, dWins === false)}`;
}
html += `</div>`;
// Verdict
const jumpBetter = weeklyJump > weeklyDaily;
html += `<div class="gg-cmp-verdict">${jumpBetter
? `🏆 <strong>Happy Jump wins</strong> — ${fmtD((weeklyJump/weeklyDaily-1)*100,0)}% more ${statLabel}/week. Worth ~$${fmt(costTotal)}/jump if you can afford it.`
: `📈 <strong>Daily Grind wins</strong> — simpler, cheaper, and more gains/week at your current stats/happy. Happy Jump becomes better with higher happy or more energy.`
}</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;
}
function calculate() {
try {
el.compareResults.style.display = "none";
if (mode === "happyjump") calculateHappyJump();
else calculateDaily();
} 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."));
}
// ─────────────────────────────────────────────────────────────────────────
// JUMP LOGGER
// Stores actual jump results and reverse-engineers true gym dots via
// least-squares fitting: minimise Σ(predicted(dots) - actual)²
// Entries expire after 7 days. Export as CSV for external analysis.
// ─────────────────────────────────────────────────────────────────────────
const LOG_KEY = "jumpLog_v1";
const LOG_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
function logLoad() {
try { return JSON.parse(_load(LOG_KEY, "[]")); } catch(_) { return []; }
}
function logSave(entries) {
_save(LOG_KEY, JSON.stringify(entries));
}
function logPrune(entries) {
const cutoff = Date.now() - LOG_TTL;
return entries.filter(e => e.ts > cutoff);
}
// Predict total gain for a given dots value using current formula
function predictJump(entry, dots) {
const consts = STAT_CONSTS[entry.stat];
const ePerTrain = entry.ePerTrain || 10;
// Infer energy used — handle both new format (energyUsed field) and
// old format (stackTrains=100 means 1000E, stackTrains=15 means 150E refill)
let energy = entry.energyUsed;
if (!energy) {
if (entry.stackTrains >= 50) energy = entry.stackTrains * ePerTrain; // old multi-train format
else if (entry.stackTrains === 1) energy = 1000; // old single-entry = 1000E jump
else energy = ePerTrain; // fallback
}
const numTrains = Math.round(energy / ePerTrain);
return simulateBlock(
dots, Math.round(entry.preStat), entry.jumpHappy,
entry.bonus, ePerTrain, consts,
numTrains, entry.propHappy
).totalGain;
}
// Least-squares golden-section search for best dots value
// Searches [1.0, 9.5] with sub-0.01 precision
function calibrateDots(entries) {
if (entries.length < 2) return null;
const loss = d => entries.reduce((sum, e) => {
const diff = predictJump(e, d) - e.actualGain;
return sum + diff * diff;
}, 0);
// Golden section search
const phi = (Math.sqrt(5) - 1) / 2;
let a = 1.0, b = 9.5;
let c = b - phi * (b - a);
let d = a + phi * (b - a);
for (let i = 0; i < 100; i++) {
if (loss(c) < loss(d)) { b = d; } else { a = c; }
c = b - phi * (b - a);
d = a + phi * (b - a);
if (Math.abs(b - a) < 0.001) break;
}
const bestDots = (a + b) / 2;
// Calculate R² across entries
const meanActual = entries.reduce((s,e) => s + e.actualGain, 0) / entries.length;
const ssTot = entries.reduce((s,e) => s + Math.pow(e.actualGain - meanActual, 2), 0);
const ssRes = entries.reduce((s,e) => s + Math.pow(predictJump(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 allEntries = logPrune(logLoad());
logSave(allEntries); // prune stale on render
const badge = q("#gg-logger-badge");
if (badge) badge.textContent = allEntries.length > 0 ? `${allEntries.length} entries` : "";
const entriesEl = q("#gg-log-entries");
const calibEl = q("#gg-log-calibration");
if (!entriesEl) return;
if (allEntries.length === 0) {
entriesEl.innerHTML = `<div style="font-size:11px;color:#445;padding:4px 0">No jumps logged yet.</div>`;
calibEl.innerHTML = "";
return;
}
// Render entry list
const rows = allEntries.map((e, i) => {
const age = Math.round((Date.now() - e.ts) / 3600000);
const ageStr = age < 1 ? "just now" : age < 24 ? `${age}h ago` : `${Math.round(age/24)}d ago`;
const predicted = predictJump(e, e.gymDots);
const error = e.actualGain - predicted;
const errPct = (error / predicted * 100).toFixed(1);
const errColor = Math.abs(parseFloat(errPct)) > 5 ? "#bf7a3a" : "#4a7a4a";
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px solid #1e1e1e;font-size:11px">
<div>
<span style="color:#7a7aaa">#${i+1}</span>
<span style="color:#667;margin:0 4px">·</span>
<span style="color:#8a8">${STAT_LABELS[e.stat] || e.stat}</span>
<span style="color:#667;margin:0 4px">·</span>
<span style="color:#aaa">+${fmt(e.actualGain)}</span>
<span style="color:${errColor};margin-left:4px">(err ${errPct}%)</span>
</div>
<div style="color:#445">${ageStr} <button data-idx="${i}" class="gg-log-del" style="margin-left:6px;background:none;border:none;color:#663;cursor:pointer;font-size:12px">✕</button></div>
</div>`;
}).join('');
entriesEl.innerHTML = `<div style="margin-bottom:4px;font-size:10px;color:#445;text-transform:uppercase;letter-spacing:.05em">Logged Jumps (last 7 days)</div>${rows}`;
// Wire delete buttons
entriesEl.querySelectorAll(".gg-log-del").forEach(btn => {
btn.addEventListener("click", () => {
const entries = logLoad();
entries.splice(parseInt(btn.dataset.idx), 1);
logSave(entries);
renderLogger();
});
});
// Calibration panel — needs ≥2 entries
if (allEntries.length < 2) {
calibEl.innerHTML = `<div style="font-size:11px;color:#445;padding:4px 0">Log at least 2 jumps to calibrate dots.</div>`;
return;
}
const cal = calibrateDots(allEntries);
if (!cal) return;
const currentDots = getGymData()?.[STAT_KEYS[allEntries[0]?.stat]] || "?";
const dotsMatch = Math.abs(cal.dots - parseFloat(currentDots)) < 0.15;
const confidence = allEntries.length >= 5 ? "High" : allEntries.length >= 3 ? "Medium" : "Low";
const confColor = allEntries.length >= 5 ? "#4a9a4a" : allEntries.length >= 3 ? "#9a9a4a" : "#9a7a3a";
const r2Pct = (cal.r2 * 100).toFixed(1);
calibEl.innerHTML = `
<div style="margin-top:8px;padding:8px 10px;background:#141a14;border:1px solid #253025;border-radius:4px">
<div style="font-size:10px;color:#4a7a4a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">Calibrated Gym Dots</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<div>
<span style="font-size:20px;font-weight:bold;color:#7abf7a">${cal.dots}</span>
<span style="font-size:11px;color:#556;margin-left:6px">vs current ${currentDots} ${dotsMatch ? "✓ match" : "⚠ differs"}</span>
</div>
<div style="text-align:right;font-size:11px;color:#556">
<div>R² ${r2Pct}%</div>
<div>RMSE ±${fmt(Math.round(cal.rmse))}</div>
</div>
</div>
<div style="font-size:11px;color:${confColor}">Confidence: ${confidence} (${allEntries.length} jumps)</div>
${!dotsMatch && allEntries.length >= 3 ? `
<div style="margin-top:8px;padding:6px 8px;background:#1a1a10;border:1px solid #2a2818;border-radius:3px;font-size:11px;color:#9a9a4a">
⚠ Calibrated dots (${cal.dots}) differ from current (${currentDots}) by more than 0.1.
Your fallback table may be inaccurate for this gym — consider running Auto-fill to fetch API dots.
</div>` : ""}
${allEntries.length >= 5 ? `
<button id="gg-log-apply" class="gg-btn" style="margin-top:8px;width:100%;background:#182018;border-color:#2a4a2a;color:#7abf7a;font-size:11px">
Apply ${cal.dots} dots to calculator
</button>` : `<div style="font-size:10px;color:#445;margin-top:4px">Log ${5 - allEntries.length} more jump(s) to enable Apply.</div>`}
</div>`;
const applyBtn = q("#gg-log-apply");
if (applyBtn) {
applyBtn.addEventListener("click", () => {
// Find and update the current gym in GYMS with calibrated dots
const gymId = parseInt(el.gym.value);
const stat = el.stat.value;
const statKey = STAT_KEYS[stat];
const gymIdx = GYMS.findIndex(g => g.id === gymId);
if (gymIdx >= 0 && statKey) {
GYMS[gymIdx] = { ...GYMS[gymIdx], [statKey]: cal.dots };
buildGymMaps();
updateDotsDisplay();
updateBestGymPanel();
showStatus("ok", `✓ Applied calibrated dots (${cal.dots}) to ${GYMS[gymIdx].name} ${STAT_LABELS[stat]}.`);
}
});
}
}
// 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' : '#555';
lbl.style.color = autoLogEnabled ? '#7abf7a' : '#556';
lbl.textContent = autoLogEnabled
? 'Auto-log: ON — watches Train button for jump sessions'
: 'Auto-log: OFF — manual logging only';
};
q("#gg-autolog-lbl")?.closest('div')?.addEventListener('click', () => {
autoLogEnabled = !autoLogEnabled;
_save('autoLogEnabled', autoLogEnabled);
updateAutoLogUI();
// Expose to the result observer
window._ggAutoLogEnabled = autoLogEnabled;
});
window._ggAutoLogEnabled = autoLogEnabled;
updateAutoLogUI();
const addBtn = q("#gg-log-add");
const exportBtn = q("#gg-log-export");
const clearBtn = q("#gg-log-clear");
const panel = q("#gg-logger-panel");
if (!addBtn) return;
// Toggle panel
panel.querySelector(".gg-collapsible-header").addEventListener("click", () => {
panel.classList.toggle("open");
renderLogger();
});
addBtn.addEventListener("click", () => {
const gain = parseFloat(q("#gg-log-gain")?.value) || 0;
const preStat = parseFloat(q("#gg-log-stat")?.value) || 0;
const jumpHappy = parseFloat(q("#gg-log-happy")?.value) || 0;
const stackTrains = 1; // single-train jump (999E in one click)
const refillTrains = 0; // unknown for manual entries
if (!gain || !preStat || !jumpHappy) {
showStatus("warn", "⚠ Enter actual gain, pre-jump stat, and jump happy to log.");
return;
}
const gym = getGymData();
const stat = el.stat.value;
const statKey = STAT_KEYS[stat];
const gymDots = gym?.[statKey] || 0;
const propHappy= gf(el.happy) || 5025;
const ePerTrain= gym?.energy || 10;
const bonus = calcBonus(stat);
if (!gymDots) {
showStatus("warn", "⚠ Gym doesn't train this stat — select a valid gym/stat combo.");
return;
}
const entry = {
ts: Date.now(),
stat, gymId: gym?.id, gymName: gym?.name, gymDots,
preStat, jumpHappy, actualGain: gain,
stackTrains, refillTrains, propHappy, ePerTrain, bonus,
};
const entries = logPrune(logLoad());
entries.push(entry);
logSave(entries);
// Clear inputs
q("#gg-log-gain").value = "";
q("#gg-log-stat").value = "";
q("#gg-log-happy").value = "";
renderLogger();
showStatus("ok", `✓ Jump logged (${entries.length} total).`);
});
exportBtn.addEventListener("click", () => {
const entries = logPrune(logLoad());
if (!entries.length) { showStatus("warn", "⚠ No entries to export."); return; }
const header = "date,stat,gym,gymDots,preStat,jumpHappy,actualGain,energyUsed,propHappy,ePerTrain,bonus,predictedGain,errorPct";
const rows = entries.map(e => {
const predicted = predictJump(e, e.gymDots);
const errPct = ((e.actualGain - predicted) / predicted * 100).toFixed(2);
const date = new Date(e.ts).toISOString().split("T")[0];
const ePerTrain = e.ePerTrain || 10;
let energy = e.energyUsed;
if (!energy) {
if (e.stackTrains >= 50) energy = e.stackTrains * ePerTrain;
else if (e.stackTrains === 1) energy = 1000;
else energy = ePerTrain;
}
return [date, e.stat, e.gymName, e.gymDots, e.preStat, e.jumpHappy,
e.actualGain, energy, e.propHappy,
e.ePerTrain, e.bonus.toFixed(4), predicted.toFixed(1), errPct].join(",");
});
const csv = [header, ...rows].join("\n");
// Try clipboard first (works on some mobile browsers)
const doCopy = () => navigator.clipboard?.writeText(csv)
.then(() => showStatus("ok", `✓ ${entries.length} entries copied to clipboard — paste into Notes or a message.`))
.catch(() => showModal());
// Fallback: show text in a selectable modal — always works in TornPDA
const showModal = () => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:999999;display:flex;align-items:center;justify-content:center;padding:16px';
overlay.innerHTML = `
<div style="background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:16px;width:100%;max-width:500px;max-height:80vh;display:flex;flex-direction:column;gap:10px">
<div style="color:#7abf7a;font-size:13px;font-weight:bold">📋 Jump Log CSV — Select All & Copy</div>
<div style="font-size:11px;color:#556">Long-press the text below → Select All → Copy</div>
<textarea readonly style="flex:1;min-height:200px;background:#111;color:#8a8;border:1px solid #2a4a2a;border-radius:4px;padding:8px;font-size:10px;font-family:monospace;resize:none">${csv}</textarea>
<button id="gg-export-close" style="background:#181818;border:1px solid #333;color:#888;padding:8px;border-radius:4px;font-size:13px">Close</button>
</div>`;
document.body.appendChild(overlay);
// Auto-select textarea
const ta = overlay.querySelector('textarea');
setTimeout(() => { ta.focus(); ta.select(); }, 100);
overlay.querySelector('#gg-export-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
};
// Try clipboard, fall back to modal
if (navigator.clipboard) {
doCopy();
} else {
showModal();
}
});
clearBtn.addEventListener("click", () => {
if (!confirm("Clear all logged jumps?")) return;
logSave([]);
renderLogger();
showStatus("ok", "✓ Log cleared.");
});
// Pre-fill stat from current inputs when panel opens
panel.querySelector(".gg-collapsible-header").addEventListener("click", () => {
const currentStat = gf(el.statTotal);
if (currentStat > 0 && !q("#gg-log-stat").value) {
q("#gg-log-stat").value = currentStat;
}
const gym = getGymData();
const stat = el.stat.value;
const statKey = STAT_KEYS[stat];
if (gym && statKey) {
const edvd = parseInt(el.hjEDVDs?.value) || 0;
const anJob = el.hjANJob?.value === "yes";
const base = gf(el.hjBaseHappy) || gf(el.happy) || 5025;
const perDvd = anJob ? 5000 : 2500;
const xtc = el.hjEcstasy?.value === "yes";
const jumpH = Math.min((base + edvd * perDvd) * (xtc ? 2 : 1), 99999);
if (!q("#gg-log-happy").value) q("#gg-log-happy").value = jumpH;
}
}, { once:false });
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 = [
"AIO v2.8 " + 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), "Mode:" + mode,
"---", ...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.");
});
} // end gymModule
function jobModule() {
// ─────────────────────────────────────────────────────────────────────────
// JOB DATA
// ─────────────────────────────────────────────────────────────────────────
const JOBS = {
army: {
label:"Army", topRankPerk:"Spy battle stats once/day (10 pts + $5,000)",
benefits:[],
ranks:[
{name:"Private", req:{man:2, int:2, end:2 },gain:{man:3, int:1, end:2 },jpPerDay:1, jpNeeded:5, perks:[]},
{name:"Corporal", req:{man:50, int:15, end:20 },gain:{man:5, int:2, end:3 },jpPerDay:2, jpNeeded:10, perks:[]},
{name:"Sergeant", req:{man:120, int:35, end:50 },gain:{man:8, int:3, end:5 },jpPerDay:3, jpNeeded:15, perks:[]},
{name:"Master Sergeant", req:{man:325, int:60, end:115 },gain:{man:12,int:4, end:7 },jpPerDay:4, jpNeeded:20, perks:[]},
{name:"Warrant Officer", req:{man:700, int:160, end:300 },gain:{man:17,int:7, end:10},jpPerDay:5, jpNeeded:25, perks:[]},
{name:"Lieutenant", req:{man:1300, int:360, end:595 },gain:{man:20,int:9, end:11},jpPerDay:6, jpNeeded:30, perks:[]},
{name:"Major", req:{man:2550, int:490, end:900 },gain:{man:24,int:10,end:13},jpPerDay:7, jpNeeded:35, perks:[]},
{name:"Colonel", req:{man:4150, int:600, end:1100 },gain:{man:28,int:12,end:15},jpPerDay:8, jpNeeded:40, perks:[]},
{name:"Brigadier", req:{man:7500, int:1350, end:2530 },gain:{man:33,int:18,end:15},jpPerDay:9, jpNeeded:45, perks:[]},
{name:"General", req:{man:10000,int:2000, end:4000 },gain:{man:40,int:25,end:20},jpPerDay:10,jpNeeded:null,perks:["⭐ Spy battle stats once/day — active while in Army"]},
]
},
grocer: {
label:"Grocer", topRankPerk:"Steal Energy Drink (25 pts — worth ~$3–5M each)",
benefits:[],
ranks:[
{name:"Bag Boy", req:{man:2, int:2, end:2 },gain:{man:2, int:1, end:3 },jpPerDay:1,jpNeeded:5, perks:[]},
{name:"Price Labeller",req:{man:30, int:15, end:50 },gain:{man:3, int:2, end:5 },jpPerDay:2,jpNeeded:10, perks:[]},
{name:"Cashier", req:{man:50, int:35, end:120},gain:{man:5, int:3, end:8 },jpPerDay:3,jpNeeded:15, perks:[]},
{name:"Food Delivery", req:{man:120,int:60, end:225},gain:{man:10,int:5, end:15},jpPerDay:4,jpNeeded:20, perks:[]},
{name:"Manager", req:{man:250,int:200,end:500},gain:{man:15,int:10,end:20},jpPerDay:5,jpNeeded:null,perks:["⭐ Steal Energy Drink (~$3–5M each, 25 pts) — active while in Grocer"]},
]
},
casino: {
label:"Casino", topRankPerk:"Money payout ~$120–160k per use (10 pts + $100k base)",
benefits:[],
ranks:[
{name:"Dealer", req:{man:2, int:2, end:2 },gain:{man:1, int:2, end:3 },jpPerDay:1,jpNeeded:5, perks:[]},
{name:"Gaming Consultant",req:{man:35, int:50, end:120 },gain:{man:2, int:3, end:5 },jpPerDay:2,jpNeeded:10, perks:[]},
{name:"Marketing Manager",req:{man:60, int:115, end:325 },gain:{man:4, int:7, end:12},jpPerDay:3,jpNeeded:15, perks:[]},
{name:"Revenue Manager", req:{man:360,int:595, end:1300},gain:{man:9, int:11,end:20},jpPerDay:4,jpNeeded:20, perks:[]},
{name:"Casino Manager", req:{man:490,int:900, end:2550},gain:{man:10,int:13,end:24},jpPerDay:5,jpNeeded:25, perks:[]},
{name:"Casino President", req:{man:755,int:1100,end:4150},gain:{man:12,int:15,end:28},jpPerDay:6,jpNeeded:null,perks:["⭐ Count Cards ~$120–160k payout — active while in Casino"]},
]
},
medical: {
label:"Medical", topRankPerk:"Revive players for 75 energy — permanent passive, earn $500k–$1M+ per revive",
benefits:[],
ranks:[
{name:"Medical Student",req:{man:0, int:300, end:0 },gain:{man:4, int:12,end:7 },jpPerDay:1,jpNeeded:5, perks:[]},
{name:"Houseman", req:{man:100, int:600, end:150 },gain:{man:7, int:17,end:10},jpPerDay:2,jpNeeded:10, perks:[]},
{name:"Senior Houseman",req:{man:175, int:1000, end:275 },gain:{man:9, int:20,end:11},jpPerDay:3,jpNeeded:15, perks:[]},
{name:"GP", req:{man:300, int:1500, end:500 },gain:{man:10,int:24,end:13},jpPerDay:4,jpNeeded:20, perks:[]},
{name:"Consultant", req:{man:600, int:2500, end:1000},gain:{man:12,int:28,end:15},jpPerDay:5,jpNeeded:25, perks:[]},
{name:"Surgeon", req:{man:1300,int:5000, end:2000},gain:{man:18,int:33,end:15},jpPerDay:6,jpNeeded:30, perks:[]},
{name:"Brain Surgeon", req:{man:2600,int:10000,end:4000},gain:{man:20,int:40,end:25},jpPerDay:7,jpNeeded:null,perks:["⭐ Revive players for 75 energy — permanent passive, earns $500k–$1M+ per revive"]},
]
},
education: {
label:"Education", topRankPerk:"10% reduction in all education course completion times — permanent passive",
benefits:[
{statKey:"man",unlockedAtRank:0},
{statKey:"end",unlockedAtRank:2},
{statKey:"int",unlockedAtRank:4},
],
ranks:[
{name:"Recess Supervisor", req:{man:0, int:500, end:0 },gain:{man:8, int:10,end:9 },jpPerDay:1,jpNeeded:5, perks:[]},
{name:"Substitute Teacher",req:{man:300, int:750, end:500 },gain:{man:13,int:15,end:14},jpPerDay:2,jpNeeded:10, perks:[]},
{name:"Elementary Teacher",req:{man:600, int:1000,end:700 },gain:{man:15,int:20,end:17},jpPerDay:3,jpNeeded:15, perks:[]},
{name:"Secondary Teacher", req:{man:1000,int:1300,end:1000},gain:{man:20,int:25,end:20},jpPerDay:4,jpNeeded:20, perks:[]},
{name:"Professor", req:{man:1500,int:2000,end:1500},gain:{man:25,int:30,end:25},jpPerDay:5,jpNeeded:25, perks:[]},
{name:"Vice Principal", req:{man:1500,int:3000,end:1500},gain:{man:30,int:35,end:30},jpPerDay:6,jpNeeded:30, perks:[]},
{name:"Principal", req:{man:1500,int:5000,end:1500},gain:{man:30,int:40,end:30},jpPerDay:7,jpNeeded:null,perks:["⭐ 10% reduction in all education course times — permanent passive"]},
]
},
law: {
label:"Law", topRankPerk:"+5% crime experience & skill progression — permanent passive",
benefits:[],
ranks:[
{name:"Law Student", req:{man:0, int:0, end:1500 },gain:{man:15,int:15,end:20},jpPerDay:1,jpNeeded:5, perks:[]},
{name:"Paralegal", req:{man:1750,int:2500,end:5000 },gain:{man:17,int:20,end:23},jpPerDay:2,jpNeeded:10, perks:[]},
{name:"Probate Lawyer", req:{man:2500,int:5000,end:7500 },gain:{man:19,int:23,end:30},jpPerDay:3,jpNeeded:15, perks:[]},
{name:"Trial Lawyer", req:{man:3500,int:6500,end:7750 },gain:{man:25,int:27,end:35},jpPerDay:4,jpNeeded:20, perks:[]},
{name:"Circuit Court Judge",req:{man:4000,int:7250,end:10000},gain:{man:27,int:30,end:38},jpPerDay:5,jpNeeded:25, perks:[]},
{name:"Federal Judge", req:{man:6000,int:9000,end:15000},gain:{man:30,int:33,end:45},jpPerDay:6,jpNeeded:null,perks:["⭐ +5% crime experience & skill progression — permanent passive"]},
]
}
};
// ─────────────────────────────────────────────────────────────────────────
// JOB DETECTION
// ─────────────────────────────────────────────────────────────────────────
const JOB_KEYWORDS = {
education:"education",educational:"education","education system":"education",
army:"army",grocer:"grocer",grocery:"grocer","grocery store":"grocer",
casino:"casino",medical:"medical","medical system":"medical",law:"law",
};
function detectJob() {
const text = sel => document.querySelector(sel)?.textContent.toLowerCase() ?? "";
const match = t => {
for (const [k,v] of Object.entries(JOB_KEYWORDS))
if (k.length >= 3 && t.includes(k)) return v;
return null;
};
const ptWord = text('.points-text').trim().split(/\s+/)[0];
if (JOB_KEYWORDS[ptWord]) return JOB_KEYWORDS[ptWord];
const msgT = text('.info-msg-cont:not(.red) .msg');
const m1 = msgT.match(/\b(\w+)\s+points\b/);
if (m1 && JOB_KEYWORDS[m1[1]]) return JOB_KEYWORDS[m1[1]];
const m2 = msgT.match(/work in the\s+([\w ]+?)(?:\s+system|[.,\n]|$)/);
if (m2) { const k=m2[1].trim(); if (JOB_KEYWORDS[k]) return JOB_KEYWORDS[k]; if (JOB_KEYWORDS[k.split(' ')[0]]) return JOB_KEYWORDS[k.split(' ')[0]]; }
for (const e of document.querySelectorAll('.msg')) { const r=match(e.textContent.toLowerCase()); if (r) return r; }
const bm = document.body.innerText.toLowerCase().match(/work in the\s+([\w ]+?)(?:\s+system|\n|\.)/);
if (bm) { const k=bm[1].trim(); return JOB_KEYWORDS[k]||JOB_KEYWORDS[k.split(' ')[0]]||null; }
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// PERSISTENCE
// ─────────────────────────────────────────────────────────────────────────
const JT_DEFAULTS = {selectedJob:"education",currentRank:"0",currentJP:"5",manStat:"0",intStat:"0",endStat:"0",collapsed:"no"};
const jt_load = k => _load(k, JT_DEFAULTS[k]);
const jt_save = (k, v) => _save(k, v);
// ─────────────────────────────────────────────────────────────────────────
// CSS
// ─────────────────────────────────────────────────────────────────────────
const STYLES = `
.jt-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}
.jt-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}
.jt-header:hover{background:linear-gradient(135deg,#2c2c2c,#222)}
.jt-title{font-size:15px;font-weight:bold;color:#e0e0e0}
.jt-toggle{font-size:16px;color:#555;transition:transform .2s}
.jt-wrap.open .jt-toggle{transform:rotate(180deg)}
.jt-body{display:none;padding:12px}
.jt-wrap.open .jt-body{display:block}
.jt-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}
.jt-sec:first-child{margin-top:0}
.jt-field{margin-bottom:8px}
.jt-field label{display:block;font-size:12px;color:#888;margin-bottom:3px}
.jt-field select,.jt-field input[type=number]{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}
.jt-field select:focus,.jt-field input:focus{outline:none;border-color:#555;background:#282828}
.jt-sg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.jt-sg .jt-field{margin-bottom:0}
.jt-sg .jt-field label{font-size:11px}
.jt-btn-row{display:flex;gap:8px;margin-top:12px}
.jt-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}
.jt-btn:hover,.jt-btn:active{background:#2a2a2a}
.jt-btn-fill{background:#1a2518;border-color:#3a5030;color:#7abf7a}
.jt-btn-fill:hover,.jt-btn-fill:active{background:#20301e}
.jt-btn-calc{background:#18182a;border-color:#303058;color:#7a7acc}
.jt-btn-calc:hover,.jt-btn-calc:active{background:#20203a}
.jt-status{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.jt-status.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.jt-status.err{display:block;background:#201818;border:1px solid #4a2828;color:#bf7a7a}
.jt-status.warn{display:block;background:#201e10;border:1px solid #4a3a18;color:#bf9f5a}
.jt-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e1e}
.jt-row .jt-rl{color:#888;font-size:13px;flex-shrink:0}
.jt-row .jt-rv{color:#ddd;font-size:13px;text-align:right;word-break:break-word}
.jt-row.g{background:#18201a}.jt-row.g .jt-rv{color:#7abf7a}
.jt-row.r{background:#201818}.jt-row.r .jt-rv{color:#bf7a7a}
.jt-row.a{background:#201e10}.jt-row.a .jt-rv{color:#bf9f5a}
.jt-row.b{background:#181828}.jt-row.b .jt-rv{color:#7a7acc}
.jt-perk{margin-top:8px;padding:10px 12px;background:#182018;border:1px solid #2a4a2a;border-radius:4px;font-size:13px;color:#7abf7a;line-height:1.5}
.jt-perk.locked{background:#201818;border-color:#4a2828;color:#bf7a7a}
.jt-rec{display:flex;gap:8px;padding:8px 10px;margin-top:4px;background:#1c1a14;border:1px solid #302810;border-radius:4px;font-size:12px;color:#c8b060;line-height:1.5}
.jt-rec-num{flex-shrink:0;font-weight:bold;color:#a08040;font-size:13px;min-width:16px}
.jt-rec.urgent{background:#201818;border-color:#503020;color:#d08050}
.jt-rec.urgent .jt-rec-num{color:#b06030}
.pk{display:inline-block;border-radius:3px;padding:1px 5px;font-size:10px;margin-left:3px}
.pk.ok{background:#1a3a1a;color:#7abf7a}
.pk.no{background:#3a1a1a;color:#bf6060}
.jt-summary{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:4px;margin-bottom:8px}
.jt-summary-text{font-size:12px;color:#aaa;line-height:1.5;flex:1}
.jt-summary-text strong{color:#ddd}
.jt-edit-btn{flex-shrink:0;padding:4px 10px;border-radius:3px;border:1px solid #383838;background:#252525;color:#888;font-size:11px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.jt-edit-btn:hover,.jt-edit-btn:active{background:#2e2e2e;color:#bbb}
.jt-inputs{display:none}
.jt-inputs.expanded{display:block}
.jt-planner{margin-top:14px;padding:10px 12px;background:#14181e;border:1px solid #2a3040;border-radius:4px}
.jt-planner-header{display:flex;align-items:center;justify-content:space-between;cursor:pointer;-webkit-tap-highlight-color:transparent}
.jt-planner-title{font-size:12px;font-weight:bold;color:#6a8aaa;letter-spacing:.04em;text-transform:uppercase}
.jt-planner-toggle{font-size:13px;color:#445;transition:transform .2s}
.jt-planner.open .jt-planner-toggle{transform:rotate(180deg)}
.jt-planner-body{display:none;margin-top:10px}
.jt-planner.open .jt-planner-body{display:block}
.jt-prow{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:5px 8px;margin-top:3px;border-radius:3px;background:#181c24}
.jt-prow .jt-rl{color:#667;font-size:12px;flex-shrink:0}
.jt-prow .jt-rv{color:#aac;font-size:12px;text-align:right}
.jt-prow.pg .jt-rv{color:#7abf7a}
.jt-prow.pr .jt-rv{color:#bf7a7a}
.jt-prow.pa .jt-rv{color:#bf9f5a}
.jt-prow.pb .jt-rv{color:#7a7acc}
.jt-pstat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:6px}
.jt-pstat{background:#181c24;border:1px solid #222a38;border-radius:3px;padding:6px 8px;font-size:12px}
.jt-pstat-label{color:#556;font-size:10px;text-transform:uppercase;letter-spacing:.05em}
.jt-pstat-val{color:#aac;font-size:13px;font-weight:bold;margin-top:1px}
.jt-pstat-val.ok{color:#7abf7a}
.jt-pstat-days{font-size:10px;color:#445;margin-top:1px}
.jt-plan-summary{background:#0e1a28;border:1px solid #2a4a6a;border-radius:5px;padding:12px 14px;margin-top:10px;margin-bottom:4px}
.jt-plan-summary-title{font-size:10px;font-weight:bold;color:#4a7aaa;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px}
.jt-plan-step{display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #1a2a3a;align-items:flex-start}
.jt-plan-step:last-child{border-bottom:none;padding-bottom:0}
.jt-plan-num{flex-shrink:0;width:20px;height:20px;background:#1a3a5a;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:bold;color:#5a9acc;margin-top:1px}
.jt-plan-step-text{font-size:13px;color:#bbd;line-height:1.5}
.jt-plan-step-text strong{color:#ddeeff}
.jt-plan-step-text .stat-pill{display:inline-block;background:#1a2a3a;border:1px solid #2a4a6a;border-radius:3px;padding:1px 6px;font-size:11px;font-weight:bold;color:#88bbdd;margin:0 2px}
.jt-perk-line{padding:2px 8px 5px;font-size:11px;color:#5a7a3a}`;
// ─────────────────────────────────────────────────────────────────────────
// HTML
// ─────────────────────────────────────────────────────────────────────────
function buildHTML() {
const opts = (filter=null) => Object.entries(JOBS)
.filter(([k]) => !filter || k !== filter)
.map(([k,v]) => `<option value="${k}">${v.label}</option>`).join('');
return `
<div class="jt-header"><span class="jt-title">📋 Job Planner</span><span class="jt-toggle">▼</span></div>
<div class="jt-body">
<div class="jt-summary" id="jt-summary" style="display:none">
<span class="jt-summary-text" id="jt-summary-text">—</span>
<button class="jt-edit-btn" id="jt-edit-btn">✏ Edit</button>
</div>
<div class="jt-inputs expanded" id="jt-inputs">
<div class="jt-sec">Job & Rank</div>
<div class="jt-field"><label>Job</label><select id="jt-job">${opts()}</select></div>
<div class="jt-field"><label>Current Rank</label><select id="jt-rank"></select></div>
<div class="jt-field"><label>Job Points (accumulated, not spent)</label><input type="number" id="jt-jp" min="0"></div>
<div class="jt-sec">Working Stats</div>
<div class="jt-sg">
<div class="jt-field"><label>Manual</label><input type="number" id="jt-man" min="0"></div>
<div class="jt-field"><label>Intelligence</label><input type="number" id="jt-int" min="0"></div>
<div class="jt-field"><label>Endurance</label><input type="number" id="jt-end" min="0"></div>
</div>
</div>
<div class="jt-btn-row">
<button class="jt-btn jt-btn-fill" id="jt-autofill">⟳ Auto-fill</button>
<button class="jt-btn jt-btn-calc" id="jt-calc">Calculate →</button>
</div>
<div class="jt-status" id="jt-status"></div>
<div id="jt-results"></div>
<div class="jt-planner" id="jt-planner">
<div class="jt-planner-header" id="jt-planner-header">
<span class="jt-planner-title">🎯 Switch Job Planner</span>
<span class="jt-planner-toggle">▼</span>
</div>
<div class="jt-planner-body">
<div class="jt-field"><label>Target Job</label><select id="jt-plan-job">${opts("education")}</select></div>
<div class="jt-field"><label>Target Rank</label><select id="jt-plan-rank"></select></div>
<button class="jt-btn jt-btn-calc" id="jt-plan-calc" style="margin-top:8px">Calculate Plan →</button>
<div id="jt-plan-results"></div>
</div>
</div>
</div>`;
}
// ─────────────────────────────────────────────────────────────────────────
// MOUNT
// ─────────────────────────────────────────────────────────────────────────
const styleEl = document.createElement("style");
styleEl.textContent = STYLES;
document.head.appendChild(styleEl);
const wrap = document.createElement("div");
wrap.className = "jt-wrap";
wrap.innerHTML = buildHTML();
const mountTarget = document.querySelector('.content-wrapper') || document.body;
mountTarget.insertBefore(wrap, mountTarget.firstChild);
const q = sel => wrap.querySelector(sel);
const el = {
header: q(".jt-header"), summary: q("#jt-summary"),
sumText: q("#jt-summary-text"), editBtn: q("#jt-edit-btn"),
inputs: q("#jt-inputs"), job: q("#jt-job"),
rank: q("#jt-rank"), jp: q("#jt-jp"),
man: q("#jt-man"), int: q("#jt-int"),
end: q("#jt-end"), calcBtn: q("#jt-calc"),
fillBtn: q("#jt-autofill"), status: q("#jt-status"),
results: q("#jt-results"), planner: q("#jt-planner"),
planHdr: q("#jt-planner-header"), planJob: q("#jt-plan-job"),
planRank: q("#jt-plan-rank"), planCalc: q("#jt-plan-calc"),
planOut: q("#jt-plan-results"),
};
// ─────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────
const iV = e => parseInt(e.value) || 0;
const fmt = n => Math.round(n).toLocaleString();
const met = (req,m,i,e) => m>=req.man && i>=req.int && e>=req.end;
const dateIn = n => { const d=new Date(); d.setDate(d.getDate()+n); return d.toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'}); };
const row = (l,v,c="") => `<div class="jt-row ${c}"><span class="jt-rl">${l}</span><span class="jt-rv">${v}</span></div>`;
const sec = l => `<div class="jt-sec" style="margin-top:14px">${l}</div>`;
const recEl = (n,t,u=false) => `<div class="jt-rec${u?" urgent":""}"><span class="jt-rec-num">${n}</span><span>${t}</span></div>`;
const pill = (h,n) => h>=n ? `<span class="pk ok">${fmt(h)}</span>` : `${fmt(h)}<span class="pk no">-${fmt(n-h)}</span>`;
const rq = v => v > 0 ? fmt(v) : "—";
const prow = (l,v,c="") => `<div class="jt-prow ${c}"><span class="jt-rl">${l}</span><span class="jt-rv">${v}</span></div>`;
const psec = l => `<div class="jt-sec" style="margin-top:10px;font-size:10px">${l}</div>`;
const spill = s => `<span class="stat-pill">${s}</span>`;
const snum = n => `<div class="jt-plan-num">${n}</div>`;
// ─────────────────────────────────────────────────────────────────────────
// RANK DROPDOWNS
// ─────────────────────────────────────────────────────────────────────────
function populateRanks(jobKey, idx) {
el.rank.innerHTML = '';
JOBS[jobKey].ranks.forEach((r,i) => {
const o = document.createElement("option");
o.value = i; o.textContent = `${i+1}. ${r.name}`;
el.rank.appendChild(o);
});
el.rank.value = String(idx ?? 0);
}
function populatePlanRanks(jobKey) {
el.planRank.innerHTML = '';
JOBS[jobKey].ranks.forEach((r,i) => {
const o = document.createElement("option");
o.value = i; o.textContent = `${i+1}. ${r.name}`;
el.planRank.appendChild(o);
});
el.planRank.value = String(JOBS[jobKey].ranks.length - 1);
}
// ─────────────────────────────────────────────────────────────────────────
// SUMMARY BAR & PLANNER VISIBILITY
// ─────────────────────────────────────────────────────────────────────────
function showSummary(jobKey, ri, jp, man, int_, end) {
const rank = JOBS[jobKey].ranks[ri];
el.sumText.innerHTML = `<strong>${JOBS[jobKey].label}</strong> · ${rank.name} · <strong>${jp} JP</strong><br>MAN <strong>${fmt(man)}</strong> · INT <strong>${fmt(int_)}</strong> · END <strong>${fmt(end)}</strong>`;
el.summary.style.display = "";
el.inputs.classList.remove("expanded");
}
const hideSummary = () => { el.summary.style.display="none"; el.inputs.classList.add("expanded"); };
const updatePlanner = () => { el.planner.style.display = el.job.value==="education" ? "" : "none"; };
// ─────────────────────────────────────────────────────────────────────────
// RESTORE & WIRE
// ─────────────────────────────────────────────────────────────────────────
el.job.value = jt_load("selectedJob");
populateRanks(el.job.value, jt_load("currentRank"));
el.jp.value = jt_load("currentJP");
el.man.value = jt_load("manStat");
el.int.value = jt_load("intStat");
el.end.value = jt_load("endStat");
populatePlanRanks(el.planJob.value || "army");
updatePlanner();
if (jt_load("collapsed") !== "yes") wrap.classList.add("open");
el.editBtn.addEventListener("click", hideSummary);
el.header.addEventListener("click", () => { const o=wrap.classList.toggle("open"); jt_save("collapsed", o?"no":"yes"); });
el.job.addEventListener("change", () => {
jt_save("selectedJob", el.job.value);
populateRanks(el.job.value, 0); jt_save("currentRank","0");
updatePlanner(); hideSummary();
});
el.rank.addEventListener("change", () => jt_save("currentRank", el.rank.value));
el.jp.addEventListener("input", () => jt_save("currentJP", el.jp.value));
el.man.addEventListener("input", () => jt_save("manStat", el.man.value));
el.int.addEventListener("input", () => jt_save("intStat", el.int.value));
el.end.addEventListener("input", () => jt_save("endStat", el.end.value));
el.planJob.addEventListener("change", () => populatePlanRanks(el.planJob.value));
el.planHdr.addEventListener("click", () => el.planner.classList.toggle("open"));
el.fillBtn.addEventListener("click", autofill);
el.calcBtn.addEventListener("click", calculate);
el.planCalc.addEventListener("click", calculatePlan);
// ─────────────────────────────────────────────────────────────────────────
// AUTO-FILL
// ─────────────────────────────────────────────────────────────────────────
function autofill() {
const num = s => s ? parseInt(s.replace(/,/g,''),10)||0 : null;
const text = sel => { const e=document.querySelector(sel); return e ? e.textContent.trim() : null; };
const errs=[], filled=[];
const job = detectJob();
if (job) { el.job.value=job; jt_save("selectedJob",job); filled.push(JOBS[job].label); }
else errs.push("Job not detected");
let ri = 0;
const rankName = text('.jrank');
if (rankName && job) {
// Normalize: collapse hyphens/dashes to spaces for fuzzy matching
const norm = s => s.toLowerCase().replace(/[-–—]/g,' ').replace(/\s+/g,' ').trim();
ri = JOBS[job].ranks.findIndex(r => norm(r.name) === norm(rankName));
if (ri >= 0) { populateRanks(job,ri); jt_save("currentRank",String(ri)); filled.push(rankName); }
else { errs.push(`Rank "${rankName}" not recognised`); ri=0; populateRanks(job,0); }
} else if (!rankName) { errs.push("Rank not found"); if (job) populateRanks(job,0); }
const jpRaw = text('.jpoints');
if (jpRaw !== null) { const v=num(jpRaw); el.jp.value=v; jt_save("currentJP",String(v)); filled.push(`${v} JP`); }
else errs.push("JP not found");
[['.jmanLabor',el.man,"manStat","MAN"],['.jintelligence',el.int,"intStat","INT"],['.jendurance',el.end,"endStat","END"]]
.forEach(([sel,inp,key,lbl]) => {
const raw=text(sel);
if (raw!==null) { const v=num(raw); inp.value=v; jt_save(key,String(v)); filled.push(`${lbl} ${fmt(v)}`); }
else errs.push(`${lbl} not found`);
});
updatePlanner();
if (!errs.length) {
el.status.className="jt-status ok"
cache.set('workstats', {
man: parseInt(jt_load('manStat')) || 0,
int: parseInt(jt_load('intStat')) || 0,
end: parseInt(jt_load('endStat')) || 0,
});; el.status.textContent="✓ "+filled.join(" · ");
showSummary(el.job.value, parseInt(el.rank.value)||0, iV(el.jp), iV(el.man), iV(el.int), iV(el.end));
} else if (filled.length) {
el.status.className="jt-status warn"; el.status.textContent="⚠ Partial: "+filled.join(", ")+" — "+errs.join("; ");
} else {
el.status.className="jt-status err"; el.status.textContent="✗ "+errs.join(" | ");
}
}
// ─────────────────────────────────────────────────────────────────────────
// SIMULATION — Education auto-uses all unlocked JP specials each rank,
// spending surplus on the biggest bottleneck for the next rank.
// ─────────────────────────────────────────────────────────────────────────
function simulate(jobKey, startRi, startJP, startMan, startInt, startEnd) {
const ranks=JOBS[jobKey].ranks, isEdu=jobKey==="education";
let man=startMan, int_=startInt, end=startEnd, jp=startJP, ri=startRi;
const results=[{rankIdx:ri, rankName:ranks[ri].name, dayReached:0}];
for (let day=1; day<=3650; day++) {
const r=ranks[ri];
jp+=r.jpPerDay; man+=r.gain.man; int_+=r.gain.int; end+=r.gain.end;
if (isEdu) {
const boostable=new Set(JOBS.education.benefits.filter(b=>ri>=b.unlockedAtRank).map(b=>b.statKey));
if (boostable.size) {
const nReq=r.jpNeeded!==null?ranks[ri+1].req:{man:0,int:0,end:0};
let surplus=Math.max(0,jp-(r.jpNeeded??0));
while (surplus>=10) {
let best=null, bs=-1;
for (const sk of boostable) {
const have=sk==="man"?man:sk==="int"?int_:end, need=sk==="man"?nReq.man:sk==="int"?nReq.int:nReq.end;
const gain=sk==="man"?r.gain.man:sk==="int"?r.gain.int:r.gain.end;
const score=gain>0?Math.max(0,need-have)/gain:(need>have?9999:0);
if (score>bs){bs=score;best=sk;}
}
if (!best||bs===0) break;
jp-=10; surplus-=10;
if (best==="man") man+=100; else if (best==="int") int_+=100; else end+=100;
}
}
}
if (r.jpNeeded!==null && jp>=r.jpNeeded) {
const next=ranks[ri+1];
if (met(next.req,man,int_,end)) {
jp-=r.jpNeeded; ri++;
results.push({rankIdx:ri, rankName:ranks[ri].name, dayReached:day});
if (ri===ranks.length-1) break;
}
}
}
return results;
}
// ─────────────────────────────────────────────────────────────────────────
// CALCULATE
// ─────────────────────────────────────────────────────────────────────────
function calculate() {
const jobKey=el.job.value, ri=parseInt(el.rank.value)||0;
const jp=iV(el.jp), man=iV(el.man), int_=iV(el.int), end=iV(el.end);
const job=JOBS[jobKey], ranks=job.ranks, cur=ranks[ri], total=ranks.length;
const sim=simulate(jobKey,ri,jp,man,int_,end);
let html="";
html+=sec("Current Rank");
html+=row("Job",job.label)+row("Rank",`${ri+1}. ${cur.name}`,ri===total-1?"g":"");
if (cur.jpNeeded===null) {
html+=row("JP accumulated",`${fmt(jp)} — top rank`);
} else if (jobKey==="education" && ri<total-1) {
const nx=ranks[ri+1];
// Only count boost JP for stats that passive gains WON'T close
// before you'd have 10 JP available to spend.
// Days to earn 10 JP from current surplus = ceil(max(0,10-(jp-cur.jpNeeded))/jpPerDay)
const surplusNow=Math.max(0,jp-cur.jpNeeded);
const daysTo10JP=surplusNow>=10?0:Math.ceil((10-surplusNow)/cur.jpPerDay);
const immediateGapBoosts=JOBS.education.benefits
.filter(b=>ri>=b.unlockedAtRank)
.reduce((sum,b)=>{
const gap=b.statKey==="man"?Math.max(0,nx.req.man-man):b.statKey==="int"?Math.max(0,nx.req.int-int_):Math.max(0,nx.req.end-end);
if (gap<=0) return sum;
// Will passive gains close this gap before you can spend 10 JP?
const passiveGain=b.statKey==="man"?cur.gain.man:b.statKey==="int"?cur.gain.int:cur.gain.end;
const daysToClosePassively=Math.ceil(gap/passiveGain);
if (daysToClosePassively<=daysTo10JP) return sum; // passive closes it first
return sum+Math.ceil(gap/100)*10;
},0);
const effectiveNeeded=cur.jpNeeded+immediateGapBoosts;
if (immediateGapBoosts>0) {
html+=row("JP accumulated",`${fmt(jp)} / ${fmt(effectiveNeeded)} needed <span style="font-size:11px;color:#667">(${cur.jpNeeded} to promote + ${immediateGapBoosts} for stat boosts)</span>`);
} else {
html+=row("JP accumulated",`${fmt(jp)} / ${cur.jpNeeded} needed`);
}
} else {
html+=row("JP accumulated",`${fmt(jp)} / ${cur.jpNeeded} needed`);
}
if (ri<total-1) {
const nx=ranks[ri+1];
html+=sec(`Stats vs ${nx.name}`);
html+=row("Manual", pill(man,nx.req.man)+` / ${rq(nx.req.man)}`);
html+=row("Intelligence",pill(int_,nx.req.int)+` / ${rq(nx.req.int)}`);
html+=row("Endurance", pill(end,nx.req.end)+` / ${rq(nx.req.end)}`);
}
if (sim.length>1) {
html+=sec("Projected Rank Timeline");
sim.forEach((r,i)=>{ if(!i)return; html+=row(`→ ${r.rankName}`,r.dayReached===0?"✓ Now":`Day ${r.dayReached} (${dateIn(r.dayReached)})`,r.rankIdx===total-1?"g":""); });
}
html+=sec("Top Rank Perk");
html+=sim.find(r=>r.rankIdx===total-1)
?`<div class="jt-perk">${job.topRankPerk}</div>`
:`<div class="jt-perk locked">⚠ Cannot project top rank — passive gains alone cannot meet all requirements.</div>`;
html+=sec("Recommendations");
html+=buildRecs(jobKey,ri,jp,man,int_,end,sim).map((r,i)=>recEl(i+1,r.text,r.urgent)).join('');
el.results.innerHTML=html;
}
// ─────────────────────────────────────────────────────────────────────────
// RECOMMENDATIONS
// ─────────────────────────────────────────────────────────────────────────
function buildRecs(jobKey,ri,jp,man,int_,end,sim) {
const job=JOBS[jobKey], ranks=job.ranks, cur=ranks[ri], total=ranks.length, recs=[];
if (ri===total-1) {
recs.push({text:`You're at the top rank. Perk: ${job.topRankPerk}`});
if (jobKey==="medical") recs.push({text:"You can now revive players for 75 energy — earns $500k–$1M+ each. This passive stays forever; you can leave Medical now."});
if (jobKey==="law") recs.push({text:"The +5% crime exp & skill passive is yours permanently. Consider switching jobs for better working stat gains."});
return recs;
}
const nx=ranks[ri+1];
const jpLeft=Math.max(0,cur.jpNeeded-jp), dJP=jpLeft>0?Math.ceil(jpLeft/cur.jpPerDay):0;
const gaps={man:Math.max(0,nx.req.man-man),int:Math.max(0,nx.req.int-int_),end:Math.max(0,nx.req.end-end)};
const dStat={man:gaps.man>0?Math.ceil(gaps.man/cur.gain.man):0,int:gaps.int>0?Math.ceil(gaps.int/cur.gain.int):0,end:gaps.end>0?Math.ceil(gaps.end/cur.gain.end):0};
const statBlock=Math.max(dStat.man,dStat.int,dStat.end);
if (statBlock>dJP) {
const gList=["man","int","end"].filter(k=>gaps[k]>0).map(k=>`${k.toUpperCase()}: need ${fmt(gaps[k])} more (${dStat[k]}d at +${cur.gain[k]}/day)`);
recs.push({text:`Stat-blocked for ${nx.name}. ${gList.join(" · ")}`,urgent:true});
if (jobKey==="education") {
const anyUnlocked=JOBS.education.benefits.some(b=>ri>=b.unlockedAtRank);
if (anyUnlocked) {
const surp=Math.max(0,jp+statBlock*cur.jpPerDay-cur.jpNeeded);
// Find which stat is the biggest bottleneck to boost
const boostable=JOBS.education.benefits.filter(b=>ri>=b.unlockedAtRank).map(b=>b.statKey);
let bestStat=null, bestScore=-1;
for (const sk of boostable) {
const have=sk==="man"?man:sk==="int"?int_:end;
const need=sk==="man"?nx.req.man:sk==="int"?nx.req.int:nx.req.end;
const gain=sk==="man"?cur.gain.man:sk==="int"?cur.gain.int:cur.gain.end;
const score=gain>0?Math.max(0,need-have)/gain:0;
if(score>bestScore){bestScore=score;bestStat=sk;}
}
const statLabel={man:"Manual",int:"Intelligence",end:"Endurance"};
const boostNow=Math.floor(Math.max(0,jp-cur.jpNeeded)/10);
let boostText=`Spend surplus JP on ${bestStat?statLabel[bestStat]:"your bottleneck stat"} (+100 per 10 pts).`;
if(boostNow>0) boostText+=` You have ${boostNow} boost${boostNow!==1?"s":""} available right now.`;
boostText+=` You'll accumulate ~${Math.floor(surp/10)} total boosts while waiting for stats.`;
recs.push({text:boostText});
} else recs.push({text:"No JP stat specials unlocked yet. Save all JP for promotions."});
}
} else {
recs.push(jpLeft>0
?{text:`${dJP} day${dJP!==1?"s":""} until you can promote to ${nx.name}. Stats already meet requirements.`}
:{text:`✓ You can promote to ${nx.name} right now!`,urgent:true});
if (jobKey==="education"&&dJP>0) {
const boostable=JOBS.education.benefits.filter(b=>ri>=b.unlockedAtRank).map(b=>b.statKey);
if (boostable.length>0) {
const statLabel={man:"Manual",int:"Intelligence",end:"Endurance"};
const surplusNow=Math.max(0,jp-cur.jpNeeded);
const boostTotal=Math.floor((surplusNow+dJP*cur.jpPerDay)/10);
const daysTo10JP=surplusNow>=10?0:Math.ceil((10-surplusNow)/cur.jpPerDay);
const immediateGaps=boostable.filter(sk=>{
if(!gaps[sk]) return false;
const passiveGain=sk==="man"?cur.gain.man:sk==="int"?cur.gain.int:cur.gain.end;
const daysToClosePassively=Math.ceil(gaps[sk]/passiveGain);
return daysToClosePassively>daysTo10JP; // only flag if passive won't close it first
});
if (immediateGaps.length>0) {
// There are stat gaps that JP specials can close faster than passive gains
const closeable=immediateGaps.map(sk=>{
const gap=gaps[sk], boostsNeeded=Math.ceil(gap/100);
return {sk, gap, boostsNeeded, label:statLabel[sk]};
}).sort((a,b)=>a.boostsNeeded-b.boostsNeeded);
const parts=closeable.map(x=>`${x.label} (${fmt(x.gap)} gap — ${x.boostsNeeded} boost${x.boostsNeeded!==1?"s":""} of 10 JP each)`);
const totalBoostsNeeded=closeable.reduce((s,x)=>s+x.boostsNeeded,0);
let boostText=`Use JP specials to close stat gaps for ${nx.name}: ${parts.join(", ")}.`;
if (surplusNow>=10) boostText+=` You have ${Math.floor(surplusNow/10)} boost${Math.floor(surplusNow/10)!==1?"s":""} available right now.`;
if (totalBoostsNeeded<=boostTotal) boostText+=` You'll have enough boosts to cover all gaps before promotion.`;
recs.push({text:boostText,urgent:closeable.some(x=>x.boostsNeeded<=boostTotal)});
} else {
// No gaps to next rank — look ahead to rank after next
const targetRank=ranks[Math.min(ri+2,total-1)];
let bestStat=null, bestScore=-1;
for (const sk of boostable) {
const have=sk==="man"?man:sk==="int"?int_:end;
const need=sk==="man"?targetRank.req.man:sk==="int"?targetRank.req.int:targetRank.req.end;
const gain=sk==="man"?cur.gain.man:sk==="int"?cur.gain.int:cur.gain.end;
const score=gain>0?Math.max(0,need-have)/gain:0;
if(score>bestScore){bestScore=score;bestStat=sk;}
}
if (bestStat&&bestScore>0) {
let boostText=`While waiting, spend surplus JP above ${cur.jpNeeded} on ${statLabel[bestStat]}.`;
if(Math.floor(surplusNow/10)>0) boostText+=` ${Math.floor(surplusNow/10)} boost${Math.floor(surplusNow/10)!==1?"s":""} available now.`;
if(boostTotal>Math.floor(surplusNow/10)) boostText+=` ~${boostTotal} total by promotion time.`;
recs.push({text:boostText});
}
}
}
}
}
// Total JP to reach top rank
const jpToTop=ranks.slice(ri,total-1).reduce((sum,r)=>sum+(r.jpNeeded??0),0);
if (jpToTop>0) {
const jpRemaining=Math.max(0,jpToTop-jp);
recs.push({text:`Total JP needed to reach ${ranks[total-1].name}: ${fmt(jpToTop)} pts (you have ${fmt(jp)}, need ${fmt(jpRemaining)} more at ~${cur.jpPerDay}/day).`});
}
const topR=sim.find(r=>r.rankIdx===total-1);
if (topR?.dayReached>0) recs.push({text:`At this rate you'll reach ${ranks[total-1].name} in ~${topR.dayReached} days (${dateIn(topR.dayReached)}). Perk: ${job.topRankPerk}`});
const tips={army:"Save all JP for promotions. Don't spend on Str/Def boosts until General — a lump spend at high stats is far more efficient.",grocer:"Save all JP for promotions. Wait until Manager, then spend on Energy Drink steals (25 pts, ~$3–5M each).",casino:"Save all JP for promotions. The Casino President payout is the only worthwhile spend.",medical:"Save all JP for promotions. Don't spend on med steals before Brain Surgeon — the revive passive is the goal.",law:"Save all JP for promotions. Endurance is almost certainly your bottleneck for Federal Judge (needs END 15,000)."};
if (tips[jobKey]) recs.push({text:tips[jobKey]});
return recs;
}
// ─────────────────────────────────────────────────────────────────────────
// CALCULATE PLAN
// Tests every subset of unlocked Education JP specials and picks the
// combination that minimises total days (Education + target job).
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// AUTO-INIT
// ─────────────────────────────────────────────────────────────────────────
function tryInit() {
if(document.querySelector('.jrank')&&document.querySelector('.jmanLabor')){
autofill(); calculate();
wrap.classList.add("open"); jt_save("collapsed","no");
updatePlanner(); return true;
}
return false;
}
// Read battle stats from cross-page cache (written by gym module)
(function() {
const ws = cache.get('workstats');
if (ws) {
if (ws.man > 0 && !parseInt(jt_load('manStat'))) { jt_save('manStat', String(ws.man)); }
if (ws.int > 0 && !parseInt(jt_load('intStat'))) { jt_save('intStat', String(ws.int)); }
if (ws.end > 0 && !parseInt(jt_load('endStat'))) { jt_save('endStat', String(ws.end)); }
}
})();
if(!tryInit()){
const obs=new MutationObserver(()=>{if(tryInit())obs.disconnect();});
obs.observe(document.body,{childList:true,subtree:true});
setTimeout(()=>obs.disconnect(),10000);
}
} // end jobModule
function eduModule() {
const SUBJECTS = {
BIO: { label: "Biology", color: "#4a8a5a" },
BUS: { label: "Business", color: "#6a6aaa" },
CBT: { label: "Combat Training", color: "#aa5a3a" },
CMT: { label: "Computer Science", color: "#3a7aaa" },
DEF: { label: "Self Defense", color: "#8a5aaa" },
GEN: { label: "General Studies", color: "#7a7a5a" },
HAF: { label: "Health & Fitness", color: "#5a9a5a" },
HIS: { label: "History", color: "#9a7a3a" },
LAW: { label: "Law", color: "#7a5a3a" },
MTH: { label: "Mathematics", color: "#3a8a9a" },
PSY: { label: "Psychology", color: "#9a5a7a" },
SPT: { label: "Sports Science", color: "#5a9a7a" },
};
// Tier: 1=intro (always 7d), 2=mid, 3=bachelor
// prereqs: array of course codes that must be completed first
const COURSES = [
// ── BIOLOGY (9 courses) ───────────────────────────────────────────────
{ id:"BIO1340", subj:"BIO", tier:1, days:7, prereqs:[], name:"Introduction to Biochemistry", perk:"" },
{ id:"BIO2127", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"], name:"Intravenous Therapy", perk:"Use blood bags to heal yourself and others" },
{ id:"BIO2350", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"], name:"Evolution", perk:"+3% damage to chest shots" },
{ id:"BIO2360", subj:"BIO", tier:2, days:28, prereqs:["BIO1340"], name:"Intermediate Biochemistry", perk:"+10% medical item effectiveness" },
{ id:"BIO2370", subj:"BIO", tier:2, days:35, prereqs:["BIO2360"], name:"Advanced Biochemistry", perk:"+10% further medical item effectiveness" },
{ id:"BIO2380", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"], name:"Fundamentals Of Neurobiology", perk:"+3% damage to throat shots" },
{ id:"BIO2390", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"], name:"Chromosomes And Gene Functions", perk:"+3% damage to stomach shots" },
{ id:"BIO2400", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"], name:"Forensic Science", perk:"Decrease opponent stealth by 25%" },
{ id:"BIO2410", subj:"BIO", tier:2, days:28, prereqs:["BIO1340"], name:"Anatomy", perk:"+3% critical hit chance" },
{ id:"BIO3420", subj:"BIO", tier:3, days:42, prereqs:["BIO2127","BIO2350","BIO2360","BIO2370","BIO2380","BIO2390","BIO2400","BIO2410"], name:"Bachelor Of Biology", perk:"Equip life/stat booster temps + unlock Pharmacy" },
// ── BUSINESS (13 courses) ────────────────────────────────────────────
{ id:"BUS1100", subj:"BUS", tier:1, days:7, prereqs:[], name:"Introduction To Business", perk:"" },
{ id:"BUS2100", subj:"BUS", tier:2, days:14, prereqs:["BUS1100"], name:"Business Ethics", perk:"Small increase in company popularity" },
{ id:"BUS2110", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Human Resource Management", perk:"Passive bonus to employee working stats" },
{ id:"BUS2120", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"E-Commerce", perk:"+2% company productivity" },
{ id:"BUS2200", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Statistics", perk:"+2% company productivity" },
{ id:"BUS2300", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Communication", perk:"+5% employee effectiveness" },
{ id:"BUS2400", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Marketing", perk:"Increase advertising effectiveness" },
{ id:"BUS2500", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Corporate Finance", perk:"+2% company productivity" },
{ id:"BUS2600", subj:"BUS", tier:2, days:28, prereqs:["BUS1100"], name:"Corporate Strategy", perk:"+7% employee effectiveness" },
{ id:"BUS2700", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Pricing Strategy", perk:"+10% product price ceiling" },
{ id:"BUS2800", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Logistics", perk:"+2% company productivity" },
{ id:"BUS2900", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"], name:"Product Management", perk:"+5% product price ceiling" },
{ id:"BUS3130", subj:"BUS", tier:3, days:42, prereqs:["BUS2100","BUS2110","BUS2120","BUS2200","BUS2300","BUS2400","BUS2500","BUS2600","BUS2700","BUS2800","BUS2900"], name:"Bachelor Of Commerce", perk:"Unlock new company size/storage/staff upgrades" },
// ── COMBAT TRAINING (10 courses) ─────────────────────────────────────
{ id:"CBT1780", subj:"CBT", tier:1, days:7, prereqs:[], name:"Introduction To Combat", perk:"" },
{ id:"CBT2125", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Shotguns", perk:"+5% accuracy with shotguns" },
{ id:"CBT2790", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Military Psychology", perk:"+3% damage in all attacks" },
{ id:"CBT2800", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of War And Technology", perk:"+5% accuracy with temporary weapons" },
{ id:"CBT2810", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Society And Warfare", perk:"+5% accuracy with melee weapons" },
{ id:"CBT2820", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Machine Guns", perk:"+5% accuracy with machine guns" },
{ id:"CBT2830", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Submachine Guns", perk:"+5% accuracy with sub-machine guns" },
{ id:"CBT2840", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Pistols", perk:"+5% accuracy with pistols" },
{ id:"CBT2850", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Rifles", perk:"+5% accuracy with rifles" },
{ id:"CBT2860", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"], name:"Study Of Heavy Artillery", perk:"+5% accuracy with heavy artillery" },
{ id:"CBT3870", subj:"CBT", tier:3, days:42, prereqs:["CBT2125","CBT2790","CBT2800","CBT2810","CBT2820","CBT2830","CBT2840","CBT2850","CBT2860"], name:"Bachelor Of Military Arts And Science", perk:"Start gaining weapon experience" },
// ── COMPUTER SCIENCE (16 courses) ────────────────────────────────────
{ id:"CMT1520", subj:"CMT", tier:1, days:7, prereqs:[], name:"Introduction To Computing", perk:"Code simple viruses" },
{ id:"CMT2128", subj:"CMT", tier:2, days:21, prereqs:["CMT2570"], name:"Overclocking", perk:"+10% overclocking cracking bonus" },
{ id:"CMT2129", subj:"CMT", tier:2, days:28, prereqs:["CMT2128"], name:"Advanced Overclocking", perk:"+15% overclocking cracking bonus" },
{ id:"CMT2130", subj:"CMT", tier:2, days:21, prereqs:["CMT2530"], name:"Web Security And Penetration Testing", perk:"+10% hacking success rate" },
{ id:"CMT2131", subj:"CMT", tier:2, days:21, prereqs:["CMT2530"], name:"Automated Data Mining & Processing", perk:"+10% data mining success rate" },
{ id:"CMT2230", subj:"CMT", tier:2, days:14, prereqs:["CMT1520"], name:"Web Design And Development", perk:"+5% company advertising effectiveness" },
{ id:"CMT2530", subj:"CMT", tier:2, days:21, prereqs:["CMT1520"], name:"Intermediate Programming", perk:"Code Polymorphic and Tunneling viruses" },
{ id:"CMT2540", subj:"CMT", tier:2, days:14, prereqs:["CMT1520"], name:"Networking", perk:"+5% hacking success rate" },
{ id:"CMT2550", subj:"CMT", tier:2, days:14, prereqs:["CMT1520"], name:"Computer Repair", perk:"+5% company productivity" },
{ id:"CMT2560", subj:"CMT", tier:2, days:28, prereqs:["CMT2530"], name:"Algorithms And Advanced Programming", perk:"Code Armored and Stealth viruses" },
{ id:"CMT2570", subj:"CMT", tier:2, days:21, prereqs:["CMT1520"], name:"Fundamentals Of Computer Architecture", perk:"+5% computer speed" },
{ id:"CMT2580", subj:"CMT", tier:2, days:21, prereqs:["CMT1520"], name:"Software Engineering", perk:"+5% virus effectiveness" },
{ id:"CMT2590", subj:"CMT", tier:2, days:28, prereqs:["CMT1520"], name:"Quantum Computing", perk:"+10% virus effectiveness" },
{ id:"CMT2600", subj:"CMT", tier:2, days:28, prereqs:["CMT1520"], name:"Natural Language Engineering", perk:"+5% virus detection avoidance" },
{ id:"CMT2610", subj:"CMT", tier:2, days:28, prereqs:["CMT2540"], name:"Computer Security And Defense", perk:"+10% hacking crime success rate" },
{ id:"CMT3620", subj:"CMT", tier:3, days:42, prereqs:["CMT2128","CMT2129","CMT2130","CMT2131","CMT2230","CMT2530","CMT2540","CMT2550","CMT2560","CMT2570","CMT2580","CMT2590","CMT2600","CMT2610"], name:"Bachelor Of Computer Science", perk:"Send mails anonymously" },
// ── SELF DEFENSE (7 courses) ──────────────────────────────────────────
{ id:"DEF1700", subj:"DEF", tier:1, days:7, prereqs:[], name:"Introduction To Self Defense", perk:"" },
{ id:"DEF2710", subj:"DEF", tier:2, days:14, prereqs:["DEF1700"], name:"Judo", perk:"+1% passive defense bonus" },
{ id:"DEF2720", subj:"DEF", tier:2, days:14, prereqs:["DEF1700"], name:"Kick Boxing", perk:"Unlock kick attack" },
{ id:"DEF2730", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"], name:"Krav Maga", perk:"+1% passive speed bonus" },
{ id:"DEF2740", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"], name:"Jujitsu", perk:"+1% passive defense bonus" },
{ id:"DEF2750", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"], name:"Tae Kwon Do", perk:"+1% passive speed bonus" },
{ id:"DEF2760", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"], name:"Muay Thai", perk:"+1% passive strength bonus" },
{ id:"DEF3770", subj:"DEF", tier:3, days:35, prereqs:["DEF2710","DEF2720","DEF2730","DEF2740","DEF2750","DEF2760"], name:"Bachelor Of Self Defense", perk:"+100% fist/kick damage" },
// ── GENERAL STUDIES (12 courses) ─────────────────────────────────────
{ id:"GEN1112", subj:"GEN", tier:1, days:7, prereqs:[], name:"Introduction To General Studies", perk:"" },
{ id:"GEN2113", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"Driving License", perk:"Drive cars in the city" },
{ id:"GEN2114", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"Astronomy", perk:"+3% city find chance" },
{ id:"GEN2115", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"Mechanical Arts", perk:"+5% city find chance" },
{ id:"GEN2116", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"General Mechanics", perk:"+5% hit increase with temporary weapons" },
{ id:"GEN2117", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"Basic English", perk:"+5% effectiveness negotiating bail" },
{ id:"GEN2118", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"Creative Writing", perk:"+5% company advertising effectiveness" },
{ id:"GEN2119", subj:"GEN", tier:2, days:21, prereqs:["GEN1112"], name:"General Science", perk:"+5% damage with temporary weapons" },
{ id:"GEN2120", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"], name:"Survival Skills", perk:"+15% hunting bonus" },
{ id:"GEN2122", subj:"GEN", tier:2, days:21, prereqs:["GEN1112"], name:"Newtonian Physics", perk:"+5% damage with thrown weapons" },
{ id:"GEN2123", subj:"GEN", tier:2, days:21, prereqs:["GEN1112"], name:"Ivory Crafting", perk:"+5% city find chance" },
{ id:"GEN3121", subj:"GEN", tier:3, days:42, prereqs:["GEN2113","GEN2114","GEN2115","GEN2116","GEN2117","GEN2118","GEN2119","GEN2120","GEN2122","GEN2123"], name:"Bachelor Of General Studies", perk:"+10% working stat gains from all education" },
// ── HEALTH & FITNESS (8 courses) ─────────────────────────────────────
{ id:"HAF1103", subj:"HAF", tier:1, days:7, prereqs:[], name:"Introduction To Health And Fitness", perk:"" },
{ id:"HAF2104", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"], name:"Aerobics", perk:"+1% passive dexterity bonus" },
{ id:"HAF2105", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"], name:"Acrobatics", perk:"+1% passive speed bonus" },
{ id:"HAF2106", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"], name:"Power Lifting", perk:"+1% passive strength bonus" },
{ id:"HAF2107", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"], name:"Yoga", perk:"+2% passive strength bonus" },
{ id:"HAF2108", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"], name:"Swimming", perk:"+1% passive dexterity bonus" },
{ id:"HAF2109", subj:"HAF", tier:2, days:28, prereqs:["HAF1103"], name:"Marathon Training", perk:"+3% passive speed bonus" },
{ id:"HAF2110", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"], name:"Sailing", perk:"+5% travel speed" },
{ id:"HAF3111", subj:"HAF", tier:3, days:35, prereqs:["HAF2104","HAF2105","HAF2106","HAF2107","HAF2108","HAF2109","HAF2110"], name:"Bachelor Of Health Sciences", perk:"+25% speed during escape + 50% reduce chance opponent flees" },
// ── HISTORY (7 courses) ───────────────────────────────────────────────
{ id:"HIS1140", subj:"HIS", tier:1, days:7, prereqs:[], name:"Introduction To History", perk:"" },
{ id:"HIS2150", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"], name:"Aims And Methods In Archaeology", perk:"+10% city find chance" },
{ id:"HIS2160", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"], name:"Ancient Japanese History", perk:"+10% damage with Japanese blade weapons" },
{ id:"HIS2170", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"], name:"Medieval History", perk:"+10% damage with clubbing weapons" },
{ id:"HIS2180", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"], name:"Medieval Archaeology", perk:"+10% damage with piercing weapons" },
{ id:"HIS2190", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"], name:"South Asian Archaeology", perk:"+10% city find chance" },
{ id:"HIS2200", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"], name:"Egyptian Archaeology", perk:"+10% damage with slashing weapons" },
{ id:"HIS3210", subj:"HIS", tier:3, days:42, prereqs:["HIS2150","HIS2160","HIS2170","HIS2180","HIS2190","HIS2200"], name:"Bachelor Of History", perk:"Unlock museum" },
// ── LAW (14 courses) ──────────────────────────────────────────────────
{ id:"LAW1880", subj:"LAW", tier:1, days:7, prereqs:[], name:"Introduction To Law", perk:"" },
{ id:"LAW2100", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Media Law", perk:"Increase advertising effectiveness" },
{ id:"LAW2101", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Revenue Law", perk:"-5% bail cost" },
{ id:"LAW2890", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Public Law", perk:"-25% nerve to escape jail" },
{ id:"LAW2900", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Common Law", perk:"Buy yourself/others out of jail while in jail" },
{ id:"LAW2910", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Property Law", perk:"-5% property upgrade cost" },
{ id:"LAW2920", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Criminal Law", perk:"+5% crime success rate" },
{ id:"LAW2930", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"], name:"Administrative Law", perk:"+5% busting skill" },
{ id:"LAW2940", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Commercial And Consumer Law", perk:"+5% company profit" },
{ id:"LAW2950", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"], name:"Family Law", perk:"-5% bail cost" },
{ id:"LAW2960", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"], name:"Labor Law", perk:"+5% employee effectiveness" },
{ id:"LAW2970", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"], name:"Social And Economic Law", perk:"+5% busting skill" },
{ id:"LAW2980", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"], name:"Use Of Force In International Law", perk:"+5% crime success rate" },
{ id:"LAW2990", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"], name:"International Human Rights", perk:"-10% bail cost" },
{ id:"LAW3102", subj:"LAW", tier:3, days:42, prereqs:["LAW2100","LAW2101","LAW2890","LAW2900","LAW2910","LAW2920","LAW2930","LAW2940","LAW2950","LAW2960","LAW2970","LAW2980","LAW2990"], name:"Bachelor Of Law", perk:"Greatly increased busting skill" },
// ── MATHEMATICS (10 courses) ─────────────────────────────────────────
{ id:"MTH1220", subj:"MTH", tier:1, days:7, prereqs:[], name:"Introduction To Mathematics", perk:"" },
{ id:"MTH2240", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Essential Foundation Mathematics", perk:"+1% passive speed bonus" },
{ id:"MTH2250", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Intermediate Mathematics", perk:"+1% passive speed bonus" },
{ id:"MTH2260", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Geometry", perk:"+1% passive defense bonus" },
{ id:"MTH2270", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Algebra", perk:"+5% ammo conservation" },
{ id:"MTH2280", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Probability", perk:"+1% company productivity" },
{ id:"MTH2290", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Trigonometry", perk:"+5% ammo conservation" },
{ id:"MTH2300", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Calculus", perk:"+5% ammo conservation" },
{ id:"MTH2310", subj:"MTH", tier:2, days:28, prereqs:["MTH1220"], name:"Discrete Mathematics", perk:"+5% ammo conservation" },
{ id:"MTH2320", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"], name:"Geometry 2", perk:"+2% passive defense bonus" },
{ id:"MTH3330", subj:"MTH", tier:3, days:42, prereqs:["MTH2240","MTH2250","MTH2260","MTH2270","MTH2280","MTH2290","MTH2300","MTH2310","MTH2320"], name:"Bachelor Of Mathematics", perk:"+20% ammo conservation" },
// ── PSYCHOLOGY (7 courses) ────────────────────────────────────────────
{ id:"PSY1630", subj:"PSY", tier:1, days:7, prereqs:[], name:"Introduction To Psychology", perk:"" },
{ id:"PSY2132", subj:"PSY", tier:2, days:21, prereqs:["PSY1630"], name:"Intrapersonal Dynamics", perk:"+5% crime success rate" },
{ id:"PSY2640", subj:"PSY", tier:2, days:14, prereqs:["PSY1630"], name:"Memory And Decision", perk:"+1% passive dexterity bonus" },
{ id:"PSY2650", subj:"PSY", tier:2, days:14, prereqs:["PSY1630"], name:"Brain And Behaviour", perk:"+2% passive dexterity bonus" },
{ id:"PSY2660", subj:"PSY", tier:2, days:21, prereqs:["PSY1630"], name:"Quantitative Methods In Psychology", perk:"+4% passive dexterity bonus" },
{ id:"PSY2670", subj:"PSY", tier:2, days:28, prereqs:["PSY1630"], name:"Applied Decision Methods", perk:"+8% passive dexterity bonus" },
{ id:"PSY2680", subj:"PSY", tier:2, days:21, prereqs:["PSY1630"], name:"Attention And Awareness", perk:"+5% city find chance" },
{ id:"PSY3690", subj:"PSY", tier:3, days:35, prereqs:["PSY2132","PSY2640","PSY2650","PSY2660","PSY2670","PSY2680"], name:"Bachelor Of Psychological Sciences", perk:"+10% crime success rate" },
// ── SPORTS SCIENCE (10 courses) — order matches Torn DOM slot positions ─
{ id:"SPT1430", subj:"SPT", tier:1, days:7, prereqs:[], name:"Introduction To Sports Science", perk:"" },
{ id:"SPT2440", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Strength And Conditioning", perk:"+1% strength gym gains" },
{ id:"SPT2450", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Physiological Testing", perk:"+1% speed gym gains" },
{ id:"SPT2460", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Human Movement Analysis", perk:"+1% defense gym gains" },
{ id:"SPT2470", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Bio Mechanical Determinants Of Skill", perk:"+1% dexterity gym gains" },
{ id:"SPT2480", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Sports Medicine", perk:"+10% temporary booster stat increases" },
{ id:"SPT2490", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Nutritional Science", perk:"+2% passive speed and strength bonus" },
{ id:"SPT2500", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"], name:"Analysis And Performance", perk:"+2% passive defense and dexterity bonus" },
{ id:"SPT2126", subj:"SPT", tier:2, days:14, prereqs:["SPT1430"], name:"Sports Administration", perk:"Unlock the Sports Shop" },
{ id:"SPT3510", subj:"SPT", tier:3, days:35, prereqs:["SPT2440","SPT2450","SPT2460","SPT2470","SPT2480","SPT2490","SPT2500","SPT2126"], name:"Bachelor Of Sports Science", perk:"+1% all gym gains + 1% all passive stats" },
];
// ── Lookup maps ──────────────────────────────────────────────────────────
const COURSE_BY_ID = Object.fromEntries(COURSES.map(c => [c.id, c]));
const BY_SUBJECT = COURSES.reduce((m, c) => { (m[c.subj] ??= []).push(c); return m; }, {});
const TOTAL_BASE_DAYS = COURSES.reduce((s, c) => s + c.days, 0);
// ── Persistence ──────────────────────────────────────────────────────────
const loadCompleted = () => { try { return new Set(JSON.parse(_load("ep_completed","[]"))); } catch(_){ return new Set(); }};
const saveCompleted = s => _save("ep_completed", JSON.stringify([...s]));
const loadQueue = () => { try { return JSON.parse(_load("ep_queue","[]")); } catch(_){ return []; }};
const saveQueue = a => _save("ep_queue", JSON.stringify(a));
// ── State ─────────────────────────────────────────────────────────────────
let completed = loadCompleted();
let queue = loadQueue();
let currentCourse = null; // { id, timeLeft (seconds) }
let reduction = parseFloat(_load("ep_reduction","0"));
// pickerOpen: set of subject keys whose picker section is expanded
const pickerOpen = new Set();
// ── Helpers ───────────────────────────────────────────────────────────────
const applyRed = d => d * (1 - reduction / 100);
const canEnroll = id => (COURSE_BY_ID[id]?.prereqs ?? []).every(p => completed.has(p));
const subjDone = s => BY_SUBJECT[s].every(c => completed.has(c.id));
const fmtD = (d,r=1) => parseFloat(d.toFixed(r));
function daysToStr(d) {
d = Math.ceil(d);
if (d <= 0) return "Done";
if (d < 7) return `${d}d`;
const w = Math.floor(d/7), r = d%7;
return r ? `${w}w ${r}d` : `${w}w`;
}
function dateIn(days) {
const d = new Date();
d.setDate(d.getDate() + Math.ceil(days));
return d.toLocaleDateString('en-GB', {day:'numeric', month:'short', year:'numeric'});
}
function remSubjDays(subj) {
return BY_SUBJECT[subj].filter(c => !completed.has(c.id)).reduce((s,c) => s + applyRed(c.days), 0);
}
// ── CSS ───────────────────────────────────────────────────────────────────
document.head.insertAdjacentHTML('beforeend', `<style>
.ep-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}
.ep-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:linear-gradient(135deg,#1e1e28,#181820);border-bottom:1px solid #2a2a38;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
.ep-hdr:active{background:#24243a}
.ep-title{font-size:15px;font-weight:bold;color:#aab8dd}
.ep-tog{font-size:16px;color:#555;transition:transform .2s}
.ep-wrap.open .ep-tog{transform:rotate(180deg)}
.ep-body{display:none;padding:12px}
.ep-wrap.open .ep-body{display:block}
.ep-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 #252535}
.ep-sec:first-child{margin-top:0}
.ep-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e28}
.ep-rl{color:#778;font-size:12px;flex-shrink:0}
.ep-rv{color:#dde;font-size:12px;text-align:right}
.ep-row.g .ep-rv{color:#7abf7a}.ep-row.b .ep-rv{color:#7a9acc}.ep-row.r .ep-rv{color:#bf7a7a}.ep-row.a .ep-rv{color:#bf9f5a}
/* status */
.ep-st{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.ep-st.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.ep-st.err{display:block;background:#201818;border:1px solid #4a2828;color:#bf7a7a}
.ep-st.warn{display:block;background:#1e1a10;border:1px solid #4a3a18;color:#bf9f5a}
/* buttons */
.ep-btns{display:flex;gap:8px;margin-top:10px}
.ep-btn{flex:1;padding:10px 8px;border-radius:4px;border:1px solid #383848;background:#222;color:#ddd;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent}
.ep-btn:active{background:#2a2a3a}
.ep-btn-api{background:#18182a;border-color:#3a3a60;color:#8a8aee}
.ep-btn-dom{background:#182028;border-color:#304060;color:#6aaade}
/* reduction */
.ep-red-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.ep-red-row label{font-size:12px;color:#778;white-space:nowrap}
.ep-red-row input{flex:1;padding:6px 10px;background:#222;border:1px solid #383848;border-radius:4px;color:#e0e0e0;font-size:14px;max-width:80px}
.ep-red-chips{display:flex;gap:4px;flex-wrap:wrap;flex:1}
.ep-chip{font-size:10px;font-weight:bold;padding:2px 7px;border-radius:10px;border:1px solid}
.ep-chip.merit{color:#8a8aee;border-color:#3a3a60;background:#14142a}
.ep-chip.princ{color:#7abf7a;border-color:#2a4a2a;background:#141e14}
.ep-chip.wsu{color:#7a9acc;border-color:#2a3a50;background:#0e1420}
/* current course */
.ep-cur{padding:10px 12px;background:#14182a;border:1px solid #2a3060;border-radius:5px;margin-bottom:8px}
.ep-cur-name{font-size:14px;color:#aabfee;font-weight:bold;margin-bottom:2px}
.ep-cur-sub{font-size:11px;margin-bottom:4px}
.ep-cur-perk{font-size:11px;color:#556;margin-bottom:6px}
.ep-cur-time{font-size:13px;color:#7a9acc}
.ep-bar{height:6px;background:#1e1e38;border-radius:3px;margin-top:8px;overflow:hidden}
.ep-bar-fill{height:100%;background:linear-gradient(90deg,#3a5aa0,#6a8acc);border-radius:3px}
.ep-bar-note{font-size:10px;color:#4a5a6a;margin-top:4px}
/* queue */
.ep-q-item{display:flex;align-items:center;gap:8px;padding:8px 10px;margin-top:3px;background:#1a1a28;border:1px solid #252535;border-radius:5px}
.ep-q-num{flex-shrink:0;width:20px;height:20px;background:#1a2040;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;color:#6a8acc}
.ep-q-body{flex:1;min-width:0}
.ep-q-name{font-size:13px;color:#ccd;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep-q-info{font-size:10px;color:#4a5a6a;margin-top:1px}
.ep-q-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
.ep-q-days{font-size:12px;font-weight:bold;color:#7a9acc}
.ep-q-del{background:none;border:none;color:#3a3a5a;cursor:pointer;font-size:18px;padding:0 2px;line-height:1;-webkit-tap-highlight-color:transparent}
.ep-q-del:active{color:#9a5a5a}
.ep-q-date{font-size:10px;color:#2a4a2a;text-align:right;padding:0 10px 4px}
.ep-q-empty{font-size:12px;color:#3a3a5a;padding:12px;text-align:center;border:1px dashed #252535;border-radius:5px;margin-top:3px}
.ep-q-total{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;margin-top:6px;background:#0e1420;border:1px solid #2a3a50;border-radius:5px}
.ep-q-total-l{font-size:12px;color:#5a7a9a}
.ep-q-total-r{font-size:13px;font-weight:bold;color:#7abfee}
/* picker */
.ep-pick{margin-top:10px;border:1px solid #252535;border-radius:6px;overflow:hidden}
.ep-pick-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#141424;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
.ep-pick-hdr:active{background:#1c1c32}
.ep-pick-title{font-size:12px;font-weight:bold;color:#6a8acc;text-transform:uppercase;letter-spacing:.05em}
.ep-pick-tog{font-size:12px;color:#3a4a6a;transition:transform .15s}
.ep-pick.open .ep-pick-tog{transform:rotate(180deg)}
.ep-pick-body{display:none;background:#0e0e1e}
.ep-pick.open .ep-pick-body{display:block}
.ep-ps{border-bottom:1px solid #1a1a2e}
.ep-ps:last-child{border-bottom:none}
.ep-ps-hdr{display:flex;align-items:center;padding:9px 12px;gap:8px;-webkit-tap-highlight-color:transparent}
.ep-ps-hdr:active{background:#141424}
.ep-ps-name{flex:1;font-size:13px;font-weight:bold;cursor:pointer}
.ep-ps-info{font-size:11px;color:#3a4a5a;flex-shrink:0}
.ep-ps-addall{flex-shrink:0;padding:5px 11px;border-radius:4px;border:1px solid #2a3a50;background:#0e1828;color:#5a8acc;font-size:11px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.ep-ps-addall:active{background:#142030}
.ep-pc-list{display:none;padding:0 4px 6px}
.ep-ps.open .ep-pc-list{display:block}
.ep-pc{display:flex;align-items:center;padding:7px 8px;border-radius:4px;cursor:pointer;-webkit-tap-highlight-color:transparent;gap:8px;margin-top:2px}
.ep-pc:active{background:#141424}
.ep-pc-icon{flex-shrink:0;font-size:13px;width:18px;text-align:center}
.ep-pc-body{flex:1;min-width:0}
.ep-pc-name{font-size:12px;color:#aab}
.ep-pc-perk{font-size:10px;color:#3a4a5a;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep-pc-days{flex-shrink:0;font-size:11px;color:#4a6a8a;min-width:30px;text-align:right}
.ep-pc.done{opacity:.35;pointer-events:none}
.ep-pc.queued .ep-pc-name{color:#4a8a4a}
.ep-pc.locked .ep-pc-name{color:#4a4a6a}
/* subjects section */
.ep-subj{margin-top:8px;border:1px solid #252535;border-radius:5px;overflow:hidden}
.ep-subj-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none}
.ep-subj-hdr:active{filter:brightness(1.15)}
.ep-subj-r{display:flex;align-items:center;gap:8px}
.ep-subj-days{font-size:12px;font-weight:bold;color:#aabfee}
.ep-subj-tog{font-size:11px;color:#445;transition:transform .15s}
.ep-subj.open .ep-subj-tog{transform:rotate(180deg)}
.ep-subj-bar{height:4px;background:#222;border-radius:0;overflow:hidden}
.ep-subj-fill{height:100%;transition:width .3s}
.ep-subj-body{display:none;border-top:1px solid #1e1e28}
.ep-subj.open .ep-subj-body{display:block}
.ep-cr{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-bottom:1px solid #1a1a28;font-size:12px}
.ep-cr:last-child{border-bottom:none}
.ep-cr-icon{flex-shrink:0;width:16px;text-align:center;font-size:13px;margin-top:1px}
.ep-cr-body{flex:1}
.ep-cr-name{line-height:1.3}
.ep-cr-perk{font-size:10px;color:#446;margin-top:1px}
.ep-cr-days{flex-shrink:0;font-size:11px;color:#445;text-align:right;white-space:nowrap}
/* summary */
.ep-sg{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px}
.ep-sb{background:#1a1a28;border:1px solid #252540;border-radius:4px;padding:7px 10px}
.ep-sb-l{font-size:10px;color:#4a5a6a;text-transform:uppercase;letter-spacing:.04em}
.ep-sb-v{font-size:13px;font-weight:bold;color:#aabfee;margin-top:1px}
</style>`);
// ── Mount ─────────────────────────────────────────────────────────────────
const wrap = document.createElement("div");
wrap.className = "ep-wrap";
const q = sel => wrap.querySelector(sel);
function mount() {
if (wrap.parentElement) return;
const eduRoot = document.querySelector('#education-root');
const cw = document.querySelector('.content-wrapper');
if (eduRoot?.parentElement) eduRoot.parentElement.insertBefore(wrap, eduRoot);
else if (cw) cw.insertBefore(wrap, cw.firstChild);
else document.body.insertBefore(wrap, document.body.firstChild);
}
mount();
if (!wrap.parentElement || wrap.parentElement === document.body) {
const obs = new MutationObserver(() => { if (document.querySelector('#education-root')) { obs.disconnect(); mount(); }});
obs.observe(document.body, {childList:true, subtree:true});
setTimeout(() => obs.disconnect(), 8000);
}
// ── Render scaffold ───────────────────────────────────────────────────────
function buildHTML() {
wrap.innerHTML = `
<div class="ep-hdr" id="ep-hdr"><span class="ep-title">🎓 Education Planner</span><span class="ep-tog">▼</span></div>
<div class="ep-body">
<div class="ep-red-row">
<label>Reduction %</label>
<input type="number" id="ep-red" min="0" max="40" step="1" value="${reduction}">
<div class="ep-red-chips" id="ep-chips"></div>
</div>
<div class="ep-btns">
<button class="ep-btn ep-btn-api" id="ep-api-btn">⟳ Auto-fill API</button>
<button class="ep-btn ep-btn-dom" id="ep-dom-btn">↺ Recalculate</button>
</div>
<div class="ep-st" id="ep-st"></div>
<div style="margin:6px 0 0;padding:8px 10px;background:#141414;border:1px solid #252525;border-radius:4px;font-size:11px;color:#556;line-height:1.7">
<strong style="color:#668">API key needs "Education" access.</strong><br>
Torn → Preferences → API Keys → <em>edit your key</em> → tick <strong>Education</strong> → Save.<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 with Education access →</a>
</div>
<div id="ep-cur-sec" style="display:none">
<div class="ep-sec">Current Course</div>
<div class="ep-cur" id="ep-cur"></div>
</div>
<div class="ep-sec">Queue Planner</div>
<div id="ep-q-list"></div>
<div id="ep-q-sum"></div>
<div class="ep-pick" id="ep-pick">
<div class="ep-pick-hdr" id="ep-pick-hdr">
<span class="ep-pick-title">+ Add Courses</span>
<span class="ep-pick-tog">▼</span>
</div>
<div class="ep-pick-body" id="ep-pick-body"></div>
</div>
<div class="ep-sec" style="margin-top:14px">All Subjects</div>
<div style="font-size:11px;color:#4a5a6a;margin-bottom:8px">✓ done · ▶ active · ○ available · 🔒 locked</div>
<div id="ep-subjs"></div>
<div class="ep-sec" style="margin-top:14px">Summary</div>
<div id="ep-sum"></div>
</div>`;
}
buildHTML();
if (_load("ep_collapsed","no") !== "yes") wrap.classList.add("open");
// ── Wire persistent events ────────────────────────────────────────────────
q("#ep-hdr").addEventListener("click", () => {
const o = wrap.classList.toggle("open");
_save("ep_collapsed", o ? "no" : "yes");
});
q("#ep-red").addEventListener("input", e => {
reduction = parseFloat(e.target.value) || 0;
_save("ep_reduction", String(reduction));
renderAll();
});
q("#ep-api-btn").addEventListener("click", doAutofill);
q("#ep-dom-btn").addEventListener("click", () => { domDetect(); renderAll(); });
q("#ep-pick-hdr").addEventListener("click", () => q("#ep-pick").classList.toggle("open"));
// ── Render functions ──────────────────────────────────────────────────────
function renderAll() {
renderCurrent();
renderQueue();
renderPickerUpdate(); // in-place update, preserves open state
renderSubjects();
renderSummary();
renderChips();
}
function renderChips() {
const el = q("#ep-chips");
if (!el) return;
const meritPct = _load("ep_merit_pct", 0);
const hasPrinc = _load("ep_has_princ", "0") === "1";
const hasWSU = _load("ep_has_wsu", "0") === "1";
let html = "";
if (meritPct > 0) html += `<span class="ep-chip merit">Merits ${meritPct}%</span>`;
if (hasPrinc) html += `<span class="ep-chip princ">Principal 10%</span>`;
if (hasWSU) html += `<span class="ep-chip wsu">WSU 10%</span>`;
el.innerHTML = html;
}
function showStatus(type, msg) {
const el = q("#ep-st");
if (!el) return;
el.className = `ep-st ${type}`;
el.textContent = msg;
}
// ── Current course ────────────────────────────────────────────────────────
function renderCurrent() {
const sec = q("#ep-cur-sec");
const box = q("#ep-cur");
if (!sec || !box) return;
if (!currentCourse) { sec.style.display = "none"; return; }
sec.style.display = "";
const c = COURSE_BY_ID[currentCourse.id];
if (!c) { box.innerHTML = `<div class="ep-cur-name">${currentCourse.id}</div>`; return; }
const base = c.days;
const red = applyRed(base);
const remD = currentCourse.timeLeft > 0 ? currentCourse.timeLeft / 86400 : 0;
const pct = red > 0 ? Math.min(100, (1 - remD / red) * 100) : 100;
const subj = SUBJECTS[c.subj];
box.innerHTML = `
<div class="ep-cur-name">${c.name}</div>
<div class="ep-cur-sub" style="color:${subj.color}">${subj.label}</div>
${c.perk ? `<div class="ep-cur-perk">🎁 ${c.perk}</div>` : ""}
<div class="ep-cur-time">${remD > 0 ? `⏱ ${daysToStr(remD)} remaining — done ${dateIn(remD)}` : "✓ Complete"}</div>
<div class="ep-bar"><div class="ep-bar-fill" style="width:${pct.toFixed(1)}%"></div></div>
<div class="ep-bar-note">Base ${base}d · ${reduction}% reduction → ${fmtD(red)}d total</div>`;
}
// ── Queue ─────────────────────────────────────────────────────────────────
function renderQueue() {
const listEl = q("#ep-q-list");
const sumEl = q("#ep-q-sum");
if (!listEl) return;
if (!queue.length) {
listEl.innerHTML = `<div class="ep-q-empty">No courses queued — use Add Courses below</div>`;
sumEl.innerHTML = "";
return;
}
const curOff = currentCourse?.timeLeft > 0 ? currentCourse.timeLeft / 86400 : 0;
let cumul = curOff, html = "";
queue.forEach((id, i) => {
const c = COURSE_BY_ID[id];
if (!c) return;
const d = applyRed(c.days);
cumul += d;
const subj = SUBJECTS[c.subj];
const flag = completed.has(id) ? "✓ " : !canEnroll(id) ? "🔒 " : "";
if (i > 0 && COURSE_BY_ID[queue[i-1]]?.subj !== c.subj)
html += `<div style="font-size:9px;color:#2a2a4a;text-align:center;padding:2px 0">· · ·</div>`;
html += `
<div class="ep-q-item">
<div class="ep-q-num">${i+1}</div>
<div class="ep-q-body">
<div class="ep-q-name">${flag}${c.name}</div>
<div class="ep-q-info" style="color:${subj.color}55">${subj.label}${c.perk ? " · " + c.perk : ""}</div>
</div>
<div class="ep-q-right">
<div class="ep-q-days">${daysToStr(d)}</div>
<button class="ep-q-del" data-id="${id}">✕</button>
</div>
</div>
<div class="ep-q-date">→ ${dateIn(cumul)}</div>`;
});
listEl.innerHTML = html;
listEl.querySelectorAll(".ep-q-del").forEach(btn =>
btn.addEventListener("click", () => {
queue = queue.filter(id => id !== btn.dataset.id);
saveQueue(queue);
renderQueue();
renderPickerUpdate();
renderSummary();
})
);
const totalD = queue.reduce((s, id) => s + (COURSE_BY_ID[id] ? applyRed(COURSE_BY_ID[id].days) : 0), 0);
sumEl.innerHTML = `
<div class="ep-q-total">
<span class="ep-q-total-l">${queue.length} course${queue.length!==1?"s":""} · ${daysToStr(totalD)}</span>
<span class="ep-q-total-r">done ${dateIn(curOff + totalD)}</span>
</div>`;
}
// ── Picker — build once, update in-place ──────────────────────────────────
let pickerBuilt = false;
function renderPickerBuild() {
const body = q("#ep-pick-body");
if (!body) return;
let html = "";
for (const [subj, meta] of Object.entries(SUBJECTS)) {
const courses = BY_SUBJECT[subj] || [];
html += `<div class="ep-ps" id="ep-ps-${subj}${pickerOpen.has(subj) ? " open" : ""}">
<div class="ep-ps-hdr">
<span class="ep-ps-name" data-subj="${subj}" style="color:${meta.color}">${meta.label}</span>
<span class="ep-ps-info" id="ep-ps-info-${subj}"></span>
<button class="ep-ps-addall" data-subj="${subj}">+ All</button>
</div>
<div class="ep-pc-list" id="ep-pcl-${subj}">`;
courses.forEach(c => {
html += `<div class="ep-pc" id="ep-pc-${c.id}" data-id="${c.id}">
<div class="ep-pc-icon" id="ep-pc-icon-${c.id}"></div>
<div class="ep-pc-body">
<div class="ep-pc-name">${c.name}</div>
${c.perk ? `<div class="ep-pc-perk">${c.perk}</div>` : ""}
</div>
<div class="ep-pc-days" id="ep-pc-days-${c.id}"></div>
</div>`;
});
html += `</div></div>`;
}
body.innerHTML = html;
pickerBuilt = true;
// Wire subject header clicks (name only — not the button)
body.querySelectorAll(".ep-ps-name").forEach(el =>
el.addEventListener("click", () => {
const subj = el.dataset.subj;
const card = q(`#ep-ps-${subj}`);
if (!card) return;
const isOpen = card.classList.toggle("open");
if (isOpen) pickerOpen.add(subj); else pickerOpen.delete(subj);
})
);
// Wire + All buttons
body.querySelectorAll(".ep-ps-addall").forEach(btn =>
btn.addEventListener("click", e => { e.stopPropagation(); queueSubject(btn.dataset.subj); })
);
// Wire course row clicks
body.querySelectorAll(".ep-pc").forEach(row =>
row.addEventListener("click", () => {
const id = row.dataset.id;
if (completed.has(id)) return;
if (queue.includes(id)) {
queue = queue.filter(q => q !== id);
} else {
queue.push(id);
}
saveQueue(queue);
renderQueue();
renderPickerUpdate(); // only updates classes/text, no DOM rebuild
renderSummary();
})
);
renderPickerUpdate();
}
function renderPickerUpdate() {
if (!pickerBuilt) { renderPickerBuild(); return; }
// Update each course row's classes and icon without rebuilding DOM
for (const [subj] of Object.entries(SUBJECTS)) {
const courses = BY_SUBJECT[subj] || [];
const notDone = courses.filter(c => !completed.has(c.id));
const allQ = notDone.length > 0 && notDone.every(c => queue.includes(c.id));
const doneCount= courses.filter(c => completed.has(c.id)).length;
const infoEl = q(`#ep-ps-info-${subj}`);
if (infoEl) infoEl.textContent = `${doneCount}/${courses.length}`;
courses.forEach(c => {
const row = q(`#ep-pc-${c.id}`);
const icon = q(`#ep-pc-icon-${c.id}`);
const days = q(`#ep-pc-days-${c.id}`);
if (!row) return;
const isDone = completed.has(c.id);
const isQueued = queue.includes(c.id);
const isLocked = !canEnroll(c.id) && !isDone;
row.className = `ep-pc${isDone?" done":isQueued?" queued":isLocked?" locked":""}`;
if (icon) icon.textContent = isDone ? "✓" : isQueued ? "⊕" : isLocked ? "🔒" : "○";
if (days) days.textContent = isDone ? "done" : daysToStr(applyRed(c.days));
});
}
}
function queueSubject(subj) {
let added = 0;
for (const c of BY_SUBJECT[subj] || []) {
if (!completed.has(c.id) && !queue.includes(c.id)) { queue.push(c.id); added++; }
}
if (added) { saveQueue(queue); renderQueue(); renderPickerUpdate(); renderSummary(); }
}
// ── All Subjects section ──────────────────────────────────────────────────
function renderSubjects() {
const el = q("#ep-subjs");
if (!el) return;
let html = "";
for (const [subj, meta] of Object.entries(SUBJECTS)) {
const courses = BY_SUBJECT[subj] || [];
const done = courses.filter(c => completed.has(c.id)).length;
const total = courses.length;
const remD = remSubjDays(subj);
const pct = total > 0 ? done/total*100 : 0;
const bach = courses.find(c => c.tier === 3);
html += `<div class="ep-subj" id="ep-subj-${subj}">
<div class="ep-subj-hdr" data-subj="${subj}" style="background:${meta.color}14;border-bottom:1px solid ${meta.color}20">
<div>
<div style="font-size:13px;font-weight:bold;color:${meta.color}">${done===total?"✓ ":""}${meta.label}</div>
<div style="font-size:11px;color:#557">${done}/${total} courses${bach&&completed.has(bach.id)?" · 🎓 done":""}</div>
</div>
<div class="ep-subj-r">
<div class="ep-subj-days">${done===total?"✓":daysToStr(remD)}</div>
<div class="ep-subj-tog">▼</div>
</div>
</div>
<div class="ep-subj-bar"><div class="ep-subj-fill" style="width:${pct.toFixed(1)}%;background:${meta.color}80"></div></div>
<div class="ep-subj-body">`;
courses.forEach(c => {
const isDone = completed.has(c.id);
const isActive = currentCourse?.id === c.id;
const isLocked = !canEnroll(c.id) && !isDone;
const isQueued = queue.includes(c.id);
const icon = isDone?"✓":isActive?"▶":isLocked?"🔒":"○";
const color = isDone?"#3a6a3a":isActive?"#5a5a9a":isLocked?"#3a3a4a":"#668";
const qBadge= isQueued?` <span style="font-size:9px;color:#4a6a8a;background:#1a2030;border:1px solid #2a3a50;border-radius:3px;padding:0 3px">queued</span>`:"";
html += `<div class="ep-cr">
<div class="ep-cr-icon" style="color:${color}">${icon}</div>
<div class="ep-cr-body">
<div class="ep-cr-name" style="color:${color}">${c.name}${qBadge}</div>
${c.perk?`<div class="ep-cr-perk">${c.perk}</div>`:""}
</div>
<div class="ep-cr-days">${isDone?"✓":`${daysToStr(applyRed(c.days))}<div style="font-size:9px;color:#334">${c.days}d base</div>`}</div>
</div>`;
});
if (bach) html += `<div style="padding:7px 10px;font-size:11px;background:#0e1418;color:${meta.color}99;border-top:1px solid #1e2028">🎓 ${bach.perk}</div>`;
html += `</div></div>`;
}
el.innerHTML = html;
el.querySelectorAll(".ep-subj-hdr").forEach(hdr =>
hdr.addEventListener("click", () => q(`#ep-subj-${hdr.dataset.subj}`)?.classList.toggle("open"))
);
}
// ── Summary ───────────────────────────────────────────────────────────────
function renderSummary() {
const el = q("#ep-sum");
if (!el) return;
const done = completed.size;
const total = COURSES.length;
const degrees = Object.keys(SUBJECTS).filter(s => subjDone(s)).length;
const remD = COURSES.filter(c => !completed.has(c.id)).reduce((s,c) => s + applyRed(c.days), 0);
const totalD = applyRed(TOTAL_BASE_DAYS);
const perks = COURSES.filter(c => completed.has(c.id) && c.perk).map(c => c.perk);
const avail = COURSES.filter(c => !completed.has(c.id) && canEnroll(c.id) && c.id !== currentCourse?.id);
let html = `<div class="ep-sg">
<div class="ep-sb"><div class="ep-sb-l">Courses</div><div class="ep-sb-v">${done}/${total} <span style="font-size:11px;color:#445">(${(done/total*100).toFixed(1)}%)</span></div></div>
<div class="ep-sb"><div class="ep-sb-l">Degrees</div><div class="ep-sb-v">${degrees}/${Object.keys(SUBJECTS).length}</div></div>
<div class="ep-sb"><div class="ep-sb-l">Remaining</div><div class="ep-sb-v" style="font-size:12px">${daysToStr(remD)}</div></div>
<div class="ep-sb"><div class="ep-sb-l">Finish</div><div class="ep-sb-v" style="font-size:11px">${dateIn(remD)}</div></div>
</div>
<div class="ep-row b" style="margin-top:8px"><span class="ep-rl">Base total</span><span class="ep-rv">${daysToStr(TOTAL_BASE_DAYS)}</span></div>
<div class="ep-row g"><span class="ep-rl">With ${reduction}% reduction</span><span class="ep-rv">${daysToStr(totalD)}</span></div>`;
if (perks.length) {
html += `<div class="ep-sec" style="margin-top:12px">Earned Bonuses (${perks.length})</div>`;
perks.forEach(p => html += `<div style="padding:4px 10px;font-size:11px;color:#7a9a7a;background:#141e14;border-left:2px solid #2a5a2a;margin-top:3px;border-radius:0 3px 3px 0">🔓 ${p}</div>`);
}
if (avail.length) {
html += `<div class="ep-sec" style="margin-top:12px">Available Now (${avail.length})</div>`;
avail.slice(0,8).forEach(c => html += `<div class="ep-row"><span class="ep-rl">${c.name}</span><span class="ep-rv">${daysToStr(applyRed(c.days))}</span></div>`);
if (avail.length > 8) html += `<div style="font-size:11px;color:#445;padding:4px 10px">…+${avail.length-8} more</div>`;
}
el.innerHTML = html;
}
// ── DOM auto-detect ───────────────────────────────────────────────────────
function domDetect() {
// Current course
const btn = document.querySelector('[class*="goToCourseBtn"]');
const cdEl = document.querySelector('.hasCountdown,[class*="hasCountdown"]');
if (btn) {
const name = btn.textContent.trim();
const match = COURSES.find(c => c.name.toLowerCase() === name.toLowerCase());
if (match) {
let t = 0;
if (cdEl) {
const tx = cdEl.textContent;
const n = s => parseInt(tx.match(new RegExp(`(\\d+)\\s*${s}`))?.[1] || 0);
t = n("day")*86400 + n("hour")*3600 + n("minute")*60 + n("second");
}
currentCourse = { id: match.id, timeLeft: t };
}
}
// Completed via slot positions
let found = 0;
document.querySelectorAll('[class*="categoryItem"]').forEach(item => {
const titleEl = item.querySelector('[class*="categoryTitle"]');
if (!titleEl) return;
const label = titleEl.textContent.trim();
const subjKey = Object.entries(SUBJECTS).find(([,v]) => v.label === label)?.[0];
if (!subjKey) return;
const courses = BY_SUBJECT[subjKey] || [];
item.querySelectorAll('[class*="courseWrapper"]').forEach((slot, idx) => {
if (idx >= courses.length) return;
const ind = slot.querySelector('[class*="courseIndicator"]');
if (!ind) return;
const cls = ind.className;
const done = cls.includes('Ghv3G') || cls.includes('completed___');
const prog = cls.includes('a9M6f') || cls.includes('inProgress');
if (done && !prog) { completed.add(courses[idx].id); found++; }
});
});
if (found || currentCourse) saveCompleted(completed);
return found > 0 || !!currentCourse;
}
// ── API auto-fill ─────────────────────────────────────────────────────────
const WSU_ID = 25, WSU_MIN = 1000000;
function doAutofill() {
let key = (_load("apiKey","") || "").trim();
if (key.length !== 16) {
const k = prompt("Enter your Torn API key (16 chars, Limited Access or higher):");
if (!k || k.trim().length !== 16) { showStatus("err","✗ Invalid API key."); return; }
key = k.trim(); _save("apiKey", key);
}
showStatus("ok","⟳ Fetching from API…");
// Single Torn API call — education+merits+perks+stocks merged
GM_xmlhttpRequest({
method:"GET",
url:`https://api.torn.com/user/?selections=education,merits,perks,stocks&key=${key}&comment=EduPlan`,
onload: r => {
try {
const d = JSON.parse(r.responseText);
if (d.error) {
const code = d.error.code;
const msg = d.error.error;
if (code === 16) {
showStatus("err",
`✗ Error 16: Key access level too low for Education data.\n\n` +
`Your key needs "education" permission.\n` +
`Go to: Torn → Preferences → API Keys → Edit your key → check "Education" → Save.\n` +
`Or delete your key and use the Auto-create link in the Gym widget ⚙ settings.`
);
} else {
showStatus("err", `✗ Error ${code}: ${msg}`);
}
return;
}
applyAPIData(d, d); // stocks are in same response
} catch(e) { showStatus("err",`✗ Parse error: ${e.message}`); }
},
onerror: () => showStatus("err","✗ Network error.")
});
}
function applyAPIData(d, stockData) {
const filled = [], notes = [];
// Current course
if (d.education_current != null) {
const raw = d.education_current;
const match = COURSES.find(c => c.name.toLowerCase() === String(raw).toLowerCase().trim());
if (match && d.education_timeleft != null) {
currentCourse = { id: match.id, timeLeft: Number(d.education_timeleft) };
filled.push(`current: ${match.name}`);
} else if (typeof raw === "number") {
notes.push(`course ID ${raw} resolved from DOM`);
}
}
// Completed courses
if (d.education_completed) {
const raw = Array.isArray(d.education_completed) ? d.education_completed : Object.values(d.education_completed);
const matched = raw.map(item => COURSES.find(c => c.name.toLowerCase() === String(item).toLowerCase().trim())?.id).filter(Boolean);
if (matched.length) { completed = new Set(matched); saveCompleted(completed); filled.push(`${matched.length} courses`); }
}
// Merits — Education Length: each point = 2%, max 10 pts = 20%
let meritPct = 0;
if (d.merits) {
const key = Object.keys(d.merits).find(k => k.toLowerCase().includes("education") && k.toLowerCase().includes("length"));
if (key) {
const val = d.merits[key];
const n = typeof val === "object" ? (val.current ?? val.level ?? val.value ?? 0) : Number(val);
meritPct = Math.min(20, n * 2);
}
}
// Perks — Principal job perk
let hasPrinc = false;
if (d.perks) {
const flat = Object.values(d.perks).flat().map(p => String(p).toLowerCase());
hasPrinc = flat.some(p => (p.includes("education") && p.includes("10") && p.includes("reduc")) || p.includes("principal"));
// Debug: save raw perk strings
_save("ep_debug_perks", JSON.stringify(flat.slice(0,20)));
}
// WSU stock — check portfolio directly
let hasWSU = false;
if (stockData?.stocks) {
const wsu = stockData.stocks[String(WSU_ID)] ?? stockData.stocks[WSU_ID];
if (wsu) {
const shares = Array.isArray(wsu) ? wsu.reduce((s,b) => s + (b.shares ?? b.quantity ?? 0), 0) : (wsu.shares ?? wsu.total_shares ?? 0);
const activeBenefit = wsu.benefit?.active === true || wsu.benefit?.active === 1;
hasWSU = activeBenefit || shares >= WSU_MIN;
}
}
// Persist reduction sources for chips display
_save("ep_merit_pct", meritPct);
_save("ep_has_princ", hasPrinc ? "1" : "0");
_save("ep_has_wsu", hasWSU ? "1" : "0");
// Apply total reduction
const total = meritPct + (hasPrinc ? 10 : 0) + (hasWSU ? 10 : 0);
if (total > 0) {
reduction = total;
_save("ep_reduction", String(reduction));
const inp = q("#ep-red");
if (inp) inp.value = reduction;
cache.set('edu_reduction', { reduction: total, meritPct, hasPrinc, hasWSU });
const parts = [];
if (meritPct) parts.push(`merits ${meritPct}%`);
if (hasPrinc) parts.push("Principal 10%");
if (hasWSU) parts.push("WSU 10%");
filled.push(`${parts.join(" + ")} = ${reduction}%`);
} else {
notes.push("no reductions found");
}
// Also run DOM detect to catch anything API missed
domDetect();
const msg = (filled.length ? "✓ " + filled.join(" · ") : "") + (notes.length ? " " + notes.join(" — ") : "");
showStatus(filled.length ? "ok" : "warn", msg.trim() || "Done");
renderAll();
}
// ── Init ──────────────────────────────────────────────────────────────────
// Check cross-page cache for reduction data from gym session
(function() {
const cached = cache.get('edu_reduction');
if (cached && typeof cached.reduction === 'number') {
if (!_load('ep_merit_pct', 0) && cached.meritPct > 0) {
_save('ep_merit_pct', cached.meritPct);
_save('ep_has_princ', cached.hasPrinc ? '1' : '0');
_save('ep_has_wsu', cached.hasWSU ? '1' : '0');
reduction = cached.reduction;
_save('ep_reduction', String(reduction));
}
}
})();
renderAll();
// Auto-detect from DOM on load, then auto-fill from API if key saved
setTimeout(() => {
const changed = domDetect();
if (changed) renderAll();
const savedKey = (_load("apiKey","") || "").trim();
const lastTs = parseInt(_load("lastAutofillTs", "0")) || 0;
if (savedKey.length === 16 && (Date.now() - lastTs) > 30 * 60000) {
// Silent auto-fill only if >30min since last autofill (saves Torn API calls)
doAutofill();
}
}, 1200);
} // end eduModule
})();