A responsive, read-only Torn adviser inside Torn for desktop and mobile.
// ==UserScript==
// @name Torn Advisor Dashboard
// @namespace torn-advisor.local
// @version 1.0.4
// @description A responsive, read-only Torn adviser inside Torn for desktop and mobile.
// @author -Versatility-
// @match https://www.torn.com/*
// @icon https://www.torn.com/favicon.ico
// @connect api.torn.com
// @connect yata.yt
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @run-at document-idle
// @license MIT
// @noframes
// ==/UserScript==
(function () {
'use strict';
const VERSION = '1.0.4';
const API_V2 = 'https://api.torn.com/v2';
const API_V1 = 'https://api.torn.com';
const KEY_STORE = 'ta_api_key';
const LEGACY_KEY_STORE = 'tfa_api_key';
const SETTINGS_STORE = 'ta_settings_v1';
const GOALS_STORE = 'ta_goals_v1';
const HISTORY_STORE = 'ta_faction_history_v1';
const CACHE_PREFIX = 'ta_cache_';
const MAX_REQUESTS_MINUTE = 75;
const requestTimes = [];
const COUNTRIES = { mex:['Mexico',26],cay:['Cayman Islands',35],can:['Canada',41],haw:['Hawaii',134],uni:['United Kingdom',159],arg:['Argentina',167],swi:['Switzerland',175],jap:['Japan',225],chi:['China',242],uae:['UAE',271],sou:['South Africa',297] };
const MARKET_ITEMS = [206,197,366,367,368,370,384,385,388];
const GYM_CONST = { strength:[1600,1700], defense:[2100,-600], speed:[1600,2000], dexterity:[1800,1500] };
const PANELS = [
['home','Home'],['status','Status'],['travel','Travel'],['market','Market'],['stocks','Stocks'],
['education','Education'],['gym','Gym'],['crimes','Crimes'],['racing','Racing'],['casino','Casino'],
['bazaar','Bazaar'],['awards','Awards'],['goals','Goals'],['faction','Faction'],['oc','OC Adviser'],
['battle','Battle Intel'],['logs','Logs'],
];
const state = { panel:'home', token:0, factionStats:new Map(), memberFilter:'all', memberSearch:'' };
let host, root, launcher;
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const money = n => `${Number(n||0)<0?'-':''}$${Math.abs(Number(n||0)).toLocaleString(undefined,{maximumFractionDigits:0})}`;
const pct = n => `${Number(n||0).toFixed(1)}%`;
function duration(seconds){seconds=Math.max(0,Math.round(Number(seconds||0)));const h=Math.floor(seconds/3600),m=Math.floor((seconds%3600)/60);return h>=24?`${Math.floor(h/24)}d ${h%24}h`:h?`${h}h ${m}m`:`${m}m`}
function relative(ts){const d=Math.max(0,Date.now()/1000-Number(ts||0));return d<60?`${Math.max(1,Math.floor(d))}s ago`:d<3600?`${Math.floor(d/60)}m ago`:d<86400?`${Math.floor(d/3600)}h ago`:`${Math.floor(d/86400)}d ago`}
function settings(){return {...{travelMultiplier:.7,sellRatio:.95,capacity:0,undercut:2},...(GM_getValue(SETTINGS_STORE,{})||{})}}
function apiKey(){return String(GM_getValue(KEY_STORE,'')||GM_getValue(LEGACY_KEY_STORE,'')||'').trim()}
function simpleHash(text){let h=2166136261;for(let i=0;i<text.length;i++){h^=text.charCodeAt(i);h=Math.imul(h,16777619)}return (h>>>0).toString(36)}
function cacheGet(key,ttl){const x=GM_getValue(CACHE_PREFIX+simpleHash(key),null);return x&&Date.now()-Number(x.at||0)<ttl?x.data:null}
function cachePut(key,data){GM_setValue(CACHE_PREFIX+simpleHash(key),{at:Date.now(),data});return data}
function clearCache(){for(const key of GM_listValues())if(key.startsWith(CACHE_PREFIX))GM_deleteValue(key)}
async function apiSlot(){while(true){const now=Date.now();while(requestTimes.length&&now-requestTimes[0]>=60000)requestTimes.shift();if(requestTimes.length<MAX_REQUESTS_MINUTE){requestTimes.push(now);return}await sleep(Math.max(250,60250-(now-requestTimes[0])))}}
function gmRequest(details){return new Promise((resolve,reject)=>GM_xmlhttpRequest({...details,anonymous:true,timeout:30000,onload:resolve,onerror:()=>reject(new Error('Network request failed')),ontimeout:()=>reject(new Error('Request timed out'))}))}
async function requestJson(url,ttl=0,force=false,headers={}){const cached=!force&&ttl?cacheGet(url,ttl):null;if(cached!==null)return cached;await apiSlot();const response=await gmRequest({method:'GET',url,headers:{Accept:'application/json',...headers}});let data;try{data=JSON.parse(response.responseText||'{}')}catch(_){throw new Error(`Unreadable response (${response.status||'no status'})`)}if(response.status<200||response.status>=300)throw new Error(`HTTP ${response.status}: ${response.statusText||'request failed'}`);if(data?.error)throw new Error(`Torn API ${data.error.code??'?'}: ${data.error.error||'Unknown error'}`);return ttl?cachePut(url,data):data}
async function v2(path,params={},ttl=0,force=false){if(!apiKey())throw new Error('Add your Torn API key in Settings first.');const q=new URLSearchParams({...params,comment:'torn-advisor-userscript'});return requestJson(`${API_V2}/${String(path).replace(/^\/+/, '')}?${q}`,ttl,force,{Authorization:`ApiKey ${apiKey()}`})}
async function v1(section,selections,id='',ttl=0,force=false){if(!apiKey())throw new Error('Add your Torn API key in Settings first.');const q=new URLSearchParams({selections:selections.join(','),comment:'torn-advisor-userscript'});return requestJson(`${API_V1}/${section}/${id}?${q}`,ttl,force,{Authorization:`ApiKey ${apiKey()}`})}
async function yata(ttl=120000,force=false){return requestJson('https://yata.yt/api/v1/travel/export/',ttl,force)}
function table(headers,rows,empty='No data returned.'){return `<div class="table-wrap"><table><thead><tr>${headers.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.length?rows.map(r=>`<tr>${r.map((x,i)=>`<td class="${i?'':'left'}">${x}</td>`).join('')}</tr>`).join(''):`<tr><td colspan="${headers.length}" class="empty">${esc(empty)}</td></tr>`}</tbody></table></div>`}
function metric(label,value,sub='',kind=''){return `<div class="metric ${kind}"><div class="label">${esc(label)}</div><div class="value">${value}</div><div class="sub">${sub}</div></div>`}
function section(title,body,tag=''){return `<section><div class="section-head"><h2>${esc(title)}</h2><span>${esc(tag)}</span></div><div class="section-body">${body}</div></section>`}
function hero(title,desc,actions=''){return `<div class="hero"><div><div class="eyebrow">Torn Advisor</div><h1>${esc(title)}</h1><p>${esc(desc)}</p></div><div class="actions">${actions}</div></div>`}
function link(url,label){return `<a class="btn" target="_blank" rel="noreferrer" href="${esc(url)}">${esc(label)} ↗</a>`}
function callout(text,kind=''){return `<div class="callout ${kind}">${text}</div>`}
function itemsMap(payload){return Object.fromEntries((payload?.items||[]).map(x=>[Number(x.id),x]))}
function travelCapacity(perks){const cfg=settings();if(Number(cfg.capacity)>0)return Number(cfg.capacity);let cap=5;for(const lines of Object.values(perks||{}))for(const line of Array.isArray(lines)?lines:[]){const m=String(line).match(/^\s*\+\s*(\d+)\s+travel item/i);if(m)cap+=Number(m[1])}return cap}
function addStyles(){const s=document.createElement('style');s.textContent=`
:host{all:initial;color-scheme:dark}*{box-sizing:border-box}button,input,select{font:inherit}.launch{position:fixed;right:16px;bottom:16px;z-index:2147483646;border:1px solid #638ce4;border-radius:999px;background:linear-gradient(135deg,#315eb8,#1b2948);color:#fff;padding:11px 16px;font:750 13px "Segoe UI",sans-serif;box-shadow:0 12px 34px #000a;cursor:pointer}.backdrop{position:fixed;inset:0;z-index:2147483647;background:#080a0ed9;backdrop-filter:blur(5px);display:grid;place-items:center;padding:12px;font:13px/1.45 "Segoe UI",system-ui,sans-serif;color:#e8ebf3}.hidden{display:none!important}.app{width:min(1540px,99vw);height:min(940px,97vh);display:grid;grid-template-columns:205px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid #3a4660;border-radius:15px;background:#10131a;box-shadow:0 28px 100px #000e}.top{grid-column:1/-1;display:flex;align-items:center;gap:9px;padding:11px 14px;border-bottom:1px solid #2b3140;background:linear-gradient(135deg,#202b43,#171b24 60%)}.brand{margin-right:auto}.brand b{font-size:16px}.brand small{display:block;color:#92a0b9}.nav{overflow:auto;padding:9px;border-right:1px solid #2b3140;background:#131720}.nav button{width:100%;text-align:left;border:1px solid transparent;border-radius:7px;background:transparent;color:#aeb7c8;padding:8px 10px;margin-bottom:3px;cursor:pointer}.nav button:hover,.nav button.active{background:#223252;border-color:#4366aa;color:#fff}.main{overflow:auto;padding:14px}.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid #364056;border-radius:7px;background:#181d27;color:#dfe6f4;padding:8px 10px;text-decoration:none;cursor:pointer}.btn:hover{border-color:#638ce4;color:#fff}.btn.primary{background:#315eb8;border-color:#5d85dc}.btn.danger{border-color:#703b43;color:#ffc0c6}.hero{display:flex;justify-content:space-between;gap:18px;padding:17px 18px;border:1px solid #394865;border-radius:12px;background:linear-gradient(135deg,#202b43,#181d28 60%);margin-bottom:11px}.eyebrow,.label{text-transform:uppercase;letter-spacing:.8px;color:#91a6d5;font-size:9px;font-weight:800}.hero h1{margin:3px 0;font-size:22px}.hero p{margin:4px 0 0;color:#9ba5b7}.actions,.toolbar{display:flex;gap:7px;flex-wrap:wrap;align-items:center}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin-bottom:11px}.metric{min-width:0;border:1px solid #2d3442;border-radius:10px;background:#181c25;padding:12px}.metric.good{border-color:#315b40}.metric.warn{border-color:#665027}.metric.bad{border-color:#703b43}.value{font-size:19px;font-weight:780;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sub{font-size:10px;color:#8f98aa;margin-top:3px}section{border:1px solid #2c3341;border-radius:11px;background:#161a22;margin-bottom:11px;overflow:hidden}.section-head{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:11px 13px;border-bottom:1px solid #2a303d;background:#141820}.section-head h2{margin:0;font-size:13px}.section-head span{color:#8f98aa;font-size:9px;text-transform:uppercase;letter-spacing:.6px}.section-body{padding:12px}.table-wrap{overflow:auto}.table-wrap table{width:100%;border-collapse:collapse}.table-wrap th,.table-wrap td{padding:8px 10px;border-bottom:1px solid #282e3a;text-align:right;white-space:nowrap;font-size:11px}.table-wrap th{position:sticky;top:0;background:#11151d;color:#929bad;text-transform:uppercase;font-size:9px;letter-spacing:.5px}.table-wrap .left,.table-wrap th:first-child{text-align:left}.table-wrap tr:hover{background:#202735}.muted{color:#8f98aa}.good-text{color:#7edb99}.warn-text{color:#edbd70}.bad-text{color:#ffabb4}.callout{border:1px solid #3b4967;background:#151c29;color:#bfcae0;border-radius:9px;padding:10px 12px}.callout.good{border-color:#315b40;background:#142019}.callout.warn{border-color:#665027;background:#211b12}.callout.bad{border-color:#703b43;background:#25171b}.bar{height:7px;background:#0d1016;border-radius:99px;overflow:hidden;margin-top:7px}.bar i{display:block;height:100%;background:linear-gradient(90deg,#4f7df0,#76a3ff)}.search,.field{border:1px solid #364056;border-radius:7px;background:#0f1218;color:#eef2fa;padding:8px 10px;min-width:0}.search{width:260px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.card{border:1px solid #2d3442;border-radius:9px;background:#151922;padding:11px}.card h3{margin:0 0 5px;font-size:12px}.pill{display:inline-block;border:1px solid #3b4354;border-radius:999px;padding:2px 7px;font-size:9px;font-weight:800}.pill.online{color:#8add9f;border-color:#315b40}.pill.idle{color:#e8ba69;border-color:#664d24}.loading{display:grid;place-items:center;min-height:300px;color:#aeb9cb}.setup{max-width:680px;margin:35px auto}.setup h2{margin-top:0}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.fields label{display:grid;gap:4px;color:#aab3c3}.empty{text-align:center!important;color:#8f98aa;padding:28px!important}.price{color:#7edb99;font-weight:750;cursor:pointer}.mobile-close{display:none}
@media(max-width:760px){.backdrop{padding:0}.app{width:100vw;height:100dvh;border-radius:0;border:0;grid-template-columns:1fr;grid-template-rows:auto auto minmax(0,1fr)}.top{grid-column:1}.nav{display:flex;overflow-x:auto;border-right:0;border-bottom:1px solid #2b3140;padding:6px}.nav button{width:auto;white-space:nowrap;margin:0 3px 0 0;padding:7px 10px}.main{padding:9px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.hero{padding:13px;flex-direction:column}.hero h1{font-size:19px}.grid,.fields{grid-template-columns:1fr}.top .brand small{display:none}.mobile-close{display:inline-flex}.search{width:100%;margin-left:0}.toolbar{align-items:stretch}}
`;s.textContent+=`.launch{right:0;top:202px;bottom:auto;width:52px;height:42px;padding:0;border:2px solid #d32979;border-right:0;border-radius:11px 0 0 11px;background:linear-gradient(145deg,#211018,#25102f 58%,#17101f);color:#ff4c9e;letter-spacing:.7px;box-shadow:-7px 0 22px #a3156955,inset 0 0 12px #9e17552e;transition:background .16s,border-color .16s,color .16s,box-shadow .16s}.launch:hover{background:linear-gradient(145deg,#59152f,#4a176b);border-color:#f03b91;color:#fff;box-shadow:-8px 0 25px #c31e7b88}.log-details{display:flex;flex-wrap:wrap;gap:5px;max-width:min(620px,48vw);white-space:normal;text-align:left}.log-detail{display:inline-flex;gap:4px;max-width:100%;white-space:normal;border:1px solid #303a4e;border-radius:6px;background:#121722;padding:3px 6px;overflow-wrap:anywhere}.log-detail b{color:#91a6d5;font-size:9px;text-transform:uppercase}@media(max-width:600px){.launch{top:176px}.top [data-refresh],.top [data-close]:last-child{display:none}.top{padding:10px}.main{min-width:0}.brand b{font-size:15px}.log-details{max-width:72vw}}`;root.appendChild(s)}
function mount(){host=document.createElement('div');host.id='torn-advisor-userscript';root=host.attachShadow({mode:'open'});addStyles();launcher=document.createElement('button');launcher.className='launch';launcher.textContent='TA';launcher.title='Open Torn Advisor';launcher.setAttribute('aria-label','Open Torn Advisor');launcher.onclick=open;root.appendChild(launcher);document.documentElement.appendChild(host)}
function shell(){let b=root.querySelector('.backdrop');if(!b){b=document.createElement('div');b.className='backdrop';root.appendChild(b)}b.classList.remove('hidden');b.innerHTML=`<div class="app"><div class="top"><div class="brand"><b>Torn Advisor</b><small>Made by -Versatility- · read-only · v${VERSION}</small></div><button class="btn" data-settings>Settings</button><button class="btn" data-refresh>Refresh</button><button class="btn mobile-close" data-close>Close</button><button class="btn" data-close>×</button></div><nav class="nav">${PANELS.map(([k,n])=>`<button data-panel="${k}" class="${state.panel===k?'active':''}">${esc(n)}</button>`).join('')}</nav><main class="main"></main></div>`;b.querySelectorAll('[data-close]').forEach(x=>x.onclick=close);b.querySelector('[data-settings]').onclick=()=>renderSettings();b.querySelector('[data-refresh]').onclick=()=>loadPanel(state.panel,true);b.querySelectorAll('[data-panel]').forEach(x=>x.onclick=()=>loadPanel(x.dataset.panel,false));return b.querySelector('.main')}
function close(){state.token++;root.querySelector('.backdrop')?.classList.add('hidden')}
function open(){if(!apiKey())renderSettings();else loadPanel(state.panel||'home')}
function renderSettings(message=''){state.token++;const main=shell(),cfg=settings(),has=Boolean(apiKey());main.innerHTML=`${hero('Settings','Your key and preferences stay inside Tampermonkey on this browser.')}${section('Connection',`${message?callout(esc(message),message.startsWith('Error')?'bad':'good'):''}<div class="fields"><label>Torn API key<input class="field" type="password" data-key placeholder="${has?'Key saved — leave blank to keep it':'Limited key recommended'}"></label><label>Travel time multiplier<input class="field" type="number" min="0.1" max="1" step="0.05" data-travel value="${cfg.travelMultiplier}"></label><label>Sell value ratio<input class="field" type="number" min="0.5" max="1" step="0.01" data-sell value="${cfg.sellRatio}"></label><label>Travel capacity (0 = derive)<input class="field" type="number" min="0" step="1" data-cap value="${cfg.capacity}"></label><label>Bazaar undercut %<input class="field" type="number" min="0" max="50" step="0.5" data-under value="${cfg.undercut}"></label></div><div class="actions" style="margin-top:12px"><button class="btn primary" data-save>Save & open dashboard</button>${has?'<button class="btn danger" data-remove>Remove key & cache</button>':''}</div><div class="callout" style="margin-top:10px">A Limited key enables the complete adviser. Full access is only needed for granular user logs. The script sends the key only to api.torn.com using Torn's Authorization header.</div>`)}`;main.querySelector('[data-save]').onclick=()=>{const k=main.querySelector('[data-key]').value.trim();if(k){GM_setValue(KEY_STORE,k);GM_deleteValue(LEGACY_KEY_STORE);clearCache()}GM_setValue(SETTINGS_STORE,{travelMultiplier:Number(main.querySelector('[data-travel]').value)||.7,sellRatio:Number(main.querySelector('[data-sell]').value)||.95,capacity:Number(main.querySelector('[data-cap]').value)||0,undercut:Number(main.querySelector('[data-under]').value)||2});if(!apiKey())return renderSettings('Error: enter an API key.');loadPanel('home',true)};main.querySelector('[data-remove]')?.addEventListener('click',()=>{GM_deleteValue(KEY_STORE);GM_deleteValue(LEGACY_KEY_STORE);GM_deleteValue(HISTORY_STORE);clearCache();renderSettings('Saved key and cached API data removed.')})}
async function loadPanel(name,force=false){state.panel=name;const token=++state.token,main=shell();root.querySelectorAll('[data-panel]').forEach(x=>x.classList.toggle('active',x.dataset.panel===name));main.innerHTML='<div class="loading">Loading adviser data…</div>';try{const result=await LOADERS[name](force,token);if(token!==state.token)return;main.innerHTML=typeof result==='string'?result:result.html;if(result?.bind)result.bind(main,token)}catch(error){if(token!==state.token)return;main.innerHTML=`${hero(PANELS.find(x=>x[0]===name)?.[1]||'Adviser','This panel could not load.')}${callout(`<b>Error:</b> ${esc(error?.message||error)}`,'bad')}`}}
async function account(force=false){return v1('user',['bars','money','travel','cooldowns','networth','basic'], '',30000,force)}
async function homePanel(force){const u=await account(force),alerts=[];for(const [k,label] of [['energy','Energy'],['nerve','Nerve'],['happy','Happy']]){const b=u[k];if(b&&Number(b.current)>=Number(b.maximum))alerts.push(`${label} is full at ${b.current}/${b.maximum}.`)}if(Number(u.money_onhand||0)>500000)alerts.push(`${money(u.money_onhand)} is exposed in your wallet.`);if(Number(u?.cooldowns?.drug||0)>0)alerts.push(`Drug cooldown: ${duration(u.cooldowns.drug)}.`);if(u.travel?.time_left)alerts.push(`Travel: ${duration(u.travel.time_left)} remaining.`);return `${hero('What should I do now?','Live priorities from your account.',link('https://www.torn.com/index.php','Open Torn'))}<div class="metrics">${metric('Energy',`${u.energy?.current||0}/${u.energy?.maximum||0}`,u.energy?.fulltime?`full in ${duration(u.energy.fulltime)}`:'')}${metric('Nerve',`${u.nerve?.current||0}/${u.nerve?.maximum||0}`,u.nerve?.fulltime?`full in ${duration(u.nerve.fulltime)}`:'')}${metric('Wallet',money(u.money_onhand),`Net worth ${money(u.networth)}`,Number(u.money_onhand)>500000?'warn':'')}${metric('Level',Number(u.level||0),`${esc(u.name||'Player')} [${Number(u.player_id||0)}]`)}</div>${section('Act on these',alerts.length?`<div class="grid">${alerts.map((x,i)=>`<div class="card"><h3>${i+1}. Priority</h3>${esc(x)}</div>`).join('')}</div>`:callout('Nothing urgent. Your bars, wallet and timers look under control.','good'))}${section('Quick links',`<div class="actions">${link('https://www.torn.com/gym.php','Gym')}${link('https://www.torn.com/loader.php?sid=crimes','Crimes')}${link('https://www.torn.com/travelagency.php','Travel')}${link('https://www.torn.com/page.php?sid=ItemMarket','Market')}${link('https://www.torn.com/factions.php?step=your','Faction')}</div>`)}`}
async function statusPanel(force){const [u,bs]=await Promise.all([account(force),v1('user',['battlestats'],'',30000,force)]);const bars=['energy','nerve','happy','life'].map(k=>{const b=u[k]||{},p=b.maximum?Math.min(100,b.current/b.maximum*100):0;return `<div class="card"><h3>${k[0].toUpperCase()+k.slice(1)} · ${b.current||0}/${b.maximum||0}</h3><div class="bar"><i style="width:${p}%"></i></div><div class="muted">${b.fulltime?`full in ${duration(b.fulltime)}`:'live'}</div></div>`}).join('');const stats=Object.keys(GYM_CONST).map(k=>[k[0].toUpperCase()+k.slice(1),Number(bs[k]||0).toLocaleString()]);return `${hero('Account Status','Bars, cash, cooldowns and battle stats.')}${section('Resources',`<div class="grid">${bars}</div>`)}<div class="metrics">${metric('Wallet',money(u.money_onhand))}${metric('Bank',money(u.city_bank?.amount||0))}${metric('Points',Number(u.points||0).toLocaleString())}${metric('Net worth',money(u.networth))}</div>${section('Battle stats',table(['Stat','Value'],stats))}${section('Cooldowns',`<div class="grid">${['drug','medical','booster'].map(k=>`<div class="card"><h3>${k[0].toUpperCase()+k.slice(1)}</h3>${duration(u.cooldowns?.[k]||0)}</div>`).join('')}</div>`)}`}
async function travelPanel(force){const cfg=settings(),[catalog,perks,u,feed]=await Promise.all([v2('torn/items',{},21600000,force),v1('user',['perks'],'',300000,force),v1('user',['money','travel'],'',30000,force),yata(120000,force)]),items=itemsMap(catalog),cap=travelCapacity(perks),cash=Number(u.money_onhand||0)+Number(u.city_bank?.amount||0),rows=[];for(const [code,block] of Object.entries(feed.stocks||{})){if(!COUNTRIES[code])continue;const [country,mins]=COUNTRIES[code],hours=mins*cfg.travelMultiplier*2/60;for(const x of block.stocks||[]){const item=items[Number(x.id)],market=Number(item?.value?.market_price||0),cost=Number(x.cost||0);if(!item?.is_tradable||!market||!cost)continue;const each=market*cfg.sellRatio-cost;if(each<=0)continue;const units=Math.min(cap,Number(x.quantity||0));rows.push({country,item:item.name,cost,market,units,stock:x.quantity,capital:cost*units,profit:each*units,hour:each*units/hours,mins:mins*cfg.travelMultiplier,age:(Date.now()/1000-Number(block.update||0))/60})}}rows.sort((a,b)=>b.hour-a.hour);const shown=(rows.filter(x=>x.units>=cap).length?rows.filter(x=>x.units>=cap):rows).slice(0,15),best=shown.find(x=>x.capital<=cash)||shown[0];return `${hero('Travel Adviser',`Capacity ${cap} · flight x${cfg.travelMultiplier} · sell at ${(cfg.sellRatio*100).toFixed(0)}%`,link('https://www.torn.com/travelagency.php','Travel Agency'))}<div class="metrics">${metric('Liquid cash',money(cash))}${metric('Capacity',cap,'items')}${metric('Best profit / hour',best?money(best.hour):'—',best?`${esc(best.item)} · ${esc(best.country)}`:'No stocked run','good')}${metric('Capital needed',best?money(best.capital):'—',best&&best.capital>cash?`${money(best.capital-cash)} short`:'affordable',best&&best.capital>cash?'warn':'')}</div>${section('Ranked live runs',table(['Destination','Item','Buy','Market','Units','Profit','$/hour','Stock'],shown.map(x=>[esc(x.country),esc(x.item),money(x.cost),money(x.market),x.units,money(x.profit),`<span class="good-text">${money(x.hour)}</span>`,Number(x.stock).toLocaleString()]),'No profitable foreign stock right now.'),'YATA live stock')}${best?section('Best move',callout(`Fly to <b>${esc(best.country)}</b> (${best.mins.toFixed(0)} min each way), buy <b>${best.units}× ${esc(best.item)}</b>, and target about <b>${money(best.profit)}</b> profit per round trip. Feed age: ${best.age.toFixed(0)} minutes.`,best.capital<=cash?'good':'warn')):''}`}
async function marketPanel(force){const cfg=settings(),catalog=await v2('torn/items',{},21600000,force),items=itemsMap(catalog),rows=[];for(const id of MARKET_ITEMS){const item=items[id];if(!item)continue;try{const p=await v2(`market/${id}/itemmarket`,{},120000,force),list=p.itemmarket?.listings?.[0],market=Number(item.value?.market_price||0);if(list&&market){const discount=(market-Number(list.price))/market*100,profit=(market*cfg.sellRatio-Number(list.price))*Number(list.amount||0);rows.push([esc(item.name),money(list.price),money(market),`<span class="${discount>=5?'good-text':''}">${pct(discount)}</span>`,Number(list.amount||0).toLocaleString(),money(profit),link(`https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${id}`,'Open')])}}catch(_){}}rows.sort((a,b)=>parseFloat(b[3].replace(/<[^>]+>|%/g,''))-parseFloat(a[3].replace(/<[^>]+>|%/g,'')));return `${hero('Item Market Scanner','Default high-liquidity items ranked by discount.',link('https://www.torn.com/page.php?sid=ItemMarket','Item Market'))}${section('Listings under rolling market value',table(['Item','Cheapest','Market','Discount','Qty','Flip profit',''],rows),'No listings returned. Refresh in a moment.')}`}
async function stocksPanel(force){const [p,catalog]=await Promise.all([v1('torn',['stocks'],'',300000,force),v2('torn/items',{},21600000,force)]),items=itemsMap(catalog),byName=Object.fromEntries(Object.values(items).map(x=>[String(x.name||'').toLowerCase(),x])),rows=[];for(const s of Object.values(p.stocks||{})){const b=s.benefit;if(!b?.requirement)continue;const cost=Number(b.requirement)*Number(s.current_price||0),desc=String(b.description||''),freq=Number(b.frequency||1),cash=desc.match(/^\$([\d,]+)/),itm=desc.match(/^(\d+)x\s+(.+)$/i);let value=0;if(cash)value=Number(cash[1].replace(/,/g,''));else if(itm){const x=byName[itm[2].trim().toLowerCase()];value=Number(itm[1])*Number(x?.value?.market_price||0)}const roi=cost&&value?value*(365/freq)/cost*100:0;rows.push([esc(s.acronym),money(cost),esc(desc),`${freq}d`,roi?pct(roi):'n/a'])}rows.sort((a,b)=>(parseFloat(b[4])||0)-(parseFloat(a[4])||0));return `${hero('Stock Benefit Adviser','Benefit blocks ranked by annual return on capital.',link('https://www.torn.com/page.php?sid=stocks','Stocks'))}${section('Priced benefit blocks',table(['Stock','Block cost','Payout','Every','Annual ROI'],rows.slice(0,20)))}`}
async function educationPanel(force){const [user,catalog]=await Promise.all([v2('user/education',{},30000,force),v2('torn/education',{},21600000,force)]),edu=user.education||{},complete=new Set((edu.complete||[]).map(Number)),courses=[];for(const subject of catalog.education||[])for(const c of subject.courses||[]){const req=(c.prerequisites?.courses||[]).map(Number),status=complete.has(Number(c.id))?'Complete':Number(edu.current?.id)===Number(c.id)?'Studying':req.every(x=>complete.has(x))?'Ready':'Locked';courses.push({...c,subject:subject.name,status,reward:c.rewards?.effect||Object.entries(c.rewards?.working_stats||{}).filter(x=>x[1]).map(([k,v])=>`+${v} ${k}`).join(', ')})}const ready=courses.filter(x=>x.status==='Ready').sort((a,b)=>Number(a.duration)-Number(b.duration)),current=courses.find(x=>x.status==='Studying');return `${hero('Education Adviser','Current course, progress and every live course.',link('https://www.torn.com/education.php','Education'))}<div class="metrics">${metric('Completed',`${complete.size}/${courses.length}`,pct(courses.length?complete.size/courses.length*100:0),'good')}${metric('Current',current?esc(current.code||current.name):'None',current?`${duration(Math.max(0,Number(edu.current?.until||0)-Date.now()/1000))} left`:'Start a course')}${metric('Ready now',ready.length,'prerequisites complete')}${metric('Shortest ready',ready[0]?duration(ready[0].duration):'—',ready[0]?esc(ready[0].name):'') }</div>${section('Recommended available courses',table(['Code','Course','Subject','Duration','Reward'],ready.slice(0,20).map(c=>[esc(c.code),esc(c.name),esc(c.subject),duration(c.duration),esc(c.reward||'Course progress')]))) }${section('All courses',table(['Code','Course','Subject','Status','Duration'],courses.map(c=>[esc(c.code),esc(c.name),esc(c.subject),`<span class="${c.status==='Complete'?'good-text':c.status==='Ready'?'warn-text':'muted'}">${c.status}</span>`,duration(c.duration)])),`${complete.size}/${courses.length} complete`)}`}
function vladar(value,stat,happy,dots,energy){const [a,b]=GYM_CONST[stat];let s=Number(value);if(s>50000000)s=50000000+(s-50000000)/(8.77635*Math.log10(s));return (s+8*Math.pow(happy,1.05)+(1-Math.pow(happy/99999,2))*a+b)/200000*(dots/10)*energy}
async function gymPanel(force){const [bs,bars,gp,ug]=await Promise.all([v1('user',['battlestats'],'',30000,force),v1('user',['bars'],'',30000,force),v1('torn',['gyms'],'',21600000,force),v1('user',['gym'],'',300000,force)]),gym=gp.gyms?.[String(ug.active_gym)]||{},energy=Number(bars.energy?.current||0),happy=Number(bars.happy?.current||0),per=Number(gym.energy||10),trains=Math.floor(energy/per),rows=Object.keys(GYM_CONST).map(stat=>{const gain=vladar(bs[stat]||0,stat,happy,Number(gym[stat]||0),per);return {stat,current:Number(bs[stat]||0),dots:Number(gym[stat]||0)/10,gain,total:gain*trains}}).sort((a,b)=>b.gain-a.gain);const lo=rows.reduce((a,b)=>a.current<b.current?a:b),hi=rows.reduce((a,b)=>a.current>b.current?a:b);return `${hero('Gym Adviser',`${esc(gym.name||'Active gym')} · ${per} energy per train`,link('https://www.torn.com/gym.php','Gym'))}<div class="metrics">${metric('Energy',`${energy}/${bars.energy?.maximum||0}`,`${trains} trains`)}${metric('Happy',happy.toLocaleString())}${metric('Best gain / train',rows[0]?rows[0].gain.toLocaleString(undefined,{maximumFractionDigits:1}):'—',rows[0]?.stat||'')}${metric('Build balance',lo.current?`${(hi.current/lo.current).toFixed(1)}:1`:'—',`${hi.stat} vs ${lo.stat}`,lo.current&&hi.current/lo.current>3?'warn':'')}</div>${section('Training plan',table(['Stat','Current','Dots','Gain / train','All energy'],rows.map(x=>[esc(x.stat),x.current.toLocaleString(),x.dots.toFixed(1),x.gain.toLocaleString(undefined,{maximumFractionDigits:1}),`<span class="good-text">${x.total.toLocaleString(undefined,{maximumFractionDigits:1})}</span>`])))}${section('Advice',callout(`Train <b>${esc(rows[0]?.stat||'your strongest gym row')}</b> for the highest modeled gain right now. Stack happy first and spend the energy in one session. This browser edition uses the same Vladar model as the desktop adviser.`,'good'))}`}
async function crimesPanel(force){const [u,b]=await Promise.all([v1('user',['crimes'],'',300000,force),v1('user',['bars'],'',30000,force)]),rec=u.criminalrecord||{},total=Number(rec.total||1),rows=Object.entries(rec).filter(x=>x[0]!=='total').sort((a,b)=>Number(b[1])-Number(a[1])).map(([k,v])=>[esc(k.replace(/([a-z])([A-Z])/g,'$1 $2').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase())),Number(v).toLocaleString(),pct(Number(v)/total*100)]),untouched=Object.entries(rec).filter(([k,v])=>k!=='total'&&Number(v)===0).map(x=>x[0]);return `${hero('Crime Adviser','Your category record and nerve priorities.',link('https://www.torn.com/loader.php?sid=crimes','Crimes'))}<div class="metrics">${metric('Nerve',`${b.nerve?.current||0}/${b.nerve?.maximum||0}`,b.nerve?.fulltime?`full in ${duration(b.nerve.fulltime)}`:'')}${metric('Offences',Number(rec.total||0).toLocaleString())}${metric('Untouched categories',untouched.length,untouched.slice(0,2).join(', '),untouched.length?'warn':'good')}${metric('Largest category',rows[0]?.[0]||'—',rows[0]?.[1]||'')}</div>${section('Criminal record',table(['Category','Count','Share'],rows))}${section('Where nerve should go',callout(untouched.length?`Never attempted: <b>${untouched.map(esc).join(', ')}</b>. Pick one category and build its skill rather than spreading nerve thin.`:'Run the hardest crime in a category that you still complete consistently.','warn'))}`}
async function racingPanel(force){const [basic,ps,tp,rp,cars]=await Promise.all([v1('user',['basic'],'',300000,force),v1('user',['personalstats'],'',300000,force),v2('racing/tracks',{},86400000,force),v2('user/races',{limit:100},120000,force),v2('user/enlistedcars',{},300000,force)]),me=Number(basic.player_id),tracks=Object.fromEntries((tp.tracks||[]).map(x=>[Number(x.id),x.title])),agg={};let finished=0;for(const race of rp.races||[]){const mine=(race.results||[]).find(x=>Number(x.driver_id)===me);if(!mine)continue;finished++;const a=agg[race.track_id]||(agg[race.track_id]={races:0,wins:0,pos:0,skill:0,crash:0});a.races++;a.wins+=Number(mine.position)===1?1:0;a.pos+=Number(mine.position||0);a.skill+=Number(race.skill_gain||0);a.crash+=mine.has_crashed?1:0}const rows=Object.entries(agg).map(([id,a])=>[esc(tracks[id]||`Track ${id}`),a.races,a.wins,pct(a.wins/a.races*100),a.pos/a.races?Number(a.pos/a.races).toFixed(2):'—',Number(a.skill/a.races).toFixed(4),pct(a.crash/a.races*100)]).sort((a,b)=>parseFloat(b[3])-parseFloat(a[3])),stats=ps.personalstats||{},fleet=(cars.enlistedcars||[]).map(c=>[esc(c.car_item_name||c.car_name||'?'),esc(c.class),Number(c.points_spent||0),Number(c.races_entered||0),pct(c.races_entered?Number(c.races_won||0)/Number(c.races_entered)*100:0),c.dirt??'—',c.tarmac??'—']);return `${hero('Racing Adviser','Your own race history, track form and fleet.',link('https://www.torn.com/loader.php?sid=racing','Racing'))}<div class="metrics">${metric('Racing skill',Number(stats.racingskill||0).toFixed(2))}${metric('Career races',Number(stats.racesentered||0).toLocaleString())}${metric('Career wins',Number(stats.raceswon||0).toLocaleString(),pct(stats.racesentered?stats.raceswon/stats.racesentered*100:0))}${metric('Recent sample',finished,rows[0]?`best: ${rows[0][0]}`:'')}</div>${section('Record by track',table(['Track','Races','Wins','Win %','Avg pos','Skill/race','Crash %'],rows))}${section('Fleet',table(['Car','Class','Points','Races','Win %','Dirt','Tarmac'],fleet))}`}
async function casinoPanel(){const games=[['Blackjack (perfect strategy)',-0.005,'Lowest house edge.'],['Russian Roulette',-0.01,'Player versus player plus house cut.'],['High-Low',-0.02,'Depends on middle-card decisions.'],['Roulette',-0.027,'Fixed wheel edge.'],['Craps',-0.04,'Varies heavily by bet.'],['Poker',null,'Skill versus players, after rake.'],['Slots',-0.08,'High edge and fast loss rate.'],['Keno',-0.10,'Lottery-style odds.']];return `${hero('Casino Adviser','Expected value, without hot-streak myths.',link('https://www.torn.com/casino.php','Casino'))}${section('Expected value per $1,000',table(['Game','House edge','Expected / $1k','Note'],games.map(([n,e,note])=>[esc(n),e===null?'varies':pct(e*100),e===null?'—':money(e*1000),esc(note)])))}${section('The honest answer',callout('Every spin, hand and roll is independent. Daily freebies are positive because they are free; otherwise the reliable money is in Travel and Stocks, not timing a casino streak.','warn'))}`}
async function bazaarPanel(force){const cfg=settings(),[catalog,baz,flowers,plushies]=await Promise.all([v2('torn/items',{},21600000,force),v2('user/bazaar',{},60000,force),v2('user/inventory',{cat:'Flower'},60000,force),v2('user/inventory',{cat:'Plushie'},60000,force)]),items=itemsMap(catalog),listed=Object.fromEntries((baz.bazaar||[]).map(x=>[Number(x.id||x.item_id),x])),rows=[];for(const inv of [flowers,plushies])for(const x of inv.inventory?.items||[]){const item=items[Number(x.id)]||x,market=Number(item.value?.market_price||0);if(!market||x.equipped)continue;const price=Math.round(market*(1-cfg.undercut/100)),cur=listed[Number(x.id)];rows.push({id:x.id,name:x.name||item.name,amount:Number(x.amount||0),market,price,total:price*Number(x.amount||0),listed:Number(cur?.price||cur?.cost||0)})}rows.sort((a,b)=>b.total-a.total);const htmlRows=rows.map(x=>[esc(x.name),x.amount,money(x.market),`<span class="price" data-price="${x.price}">${money(x.price)}</span>`,money(x.total),x.listed?money(x.listed):'—']);return {html:`${hero('Bazaar Pricing',`${cfg.undercut}% under rolling market price`,link('https://www.torn.com/bazaar.php#/add','Open Bazaar'))}<div class="metrics">${metric('Inventory value',money(rows.reduce((s,x)=>s+x.total,0)),'recommended listing total','good')}${metric('Items',rows.reduce((s,x)=>s+x.amount,0).toLocaleString())}${metric('Bazaar',baz.bazaar_is_open?'Open':'Closed','',baz.bazaar_is_open?'good':'warn')}${metric('Undercut',`${cfg.undercut}%`,'change in Settings')}</div>${section('Click a price to copy',table(['Item','Have','Market','Your price','Total','Listed now'],htmlRows))}`,bind:(main)=>main.querySelectorAll('[data-price]').forEach(x=>x.onclick=()=>{navigator.clipboard.writeText(x.dataset.price);const old=x.textContent;x.textContent='Copied';setTimeout(()=>x.textContent=old,700)})}}
async function awardsPanel(force){const [um,uh,mer,tm,th]=await Promise.all([v2('user/medals',{},300000,force),v2('user/honors',{},300000,force),v2('user/merits',{},300000,force),v2('torn/medals',{},3600000,force),v2('torn/honors',{limit:100},3600000,force)]),medals=new Set((um.medals||[]).map(x=>Number(x.id??x))),honors=new Set((uh.honors||[]).map(x=>Number(x.id??x))),catalog=[...(tm.medals||[]).map(x=>({...x,type:'Medal'})),...(th.honors||[]).map(x=>({...x,type:'Honor'}))],rows=catalog.map(x=>[esc(x.name||x.title||`Award ${x.id}`),x.type,medals.has(Number(x.id))||honors.has(Number(x.id))?'<span class="good-text">Earned</span>':'Unearned',esc(x.description||x.requirement||'')]);return `${hero('Awards & Merits','Earned awards, catalogue and merit upgrades.',link('https://www.torn.com/page.php?sid=awards','Awards'))}<div class="metrics">${metric('Medals',medals.size)}${metric('Honors',honors.size)}${metric('Unspent merits',Number(mer.merits?.available||0),'',Number(mer.merits?.available)>0?'warn':'good')}${metric('Merit upgrades',Number(mer.merits?.upgrades?.length||0))}</div>${section('Merit upgrades',table(['Upgrade','Level'],(mer.merits?.upgrades||[]).map(x=>[esc(x.name||`Merit ${x.id}`),Number(x.level||0)])))}${section('Award catalogue',table(['Award','Type','Status','Description'],rows))}`}
async function goalsPanel(force){const [b,n,p,bs]=await Promise.all([v1('user',['basic'],'',300000,force),v1('user',['networth'],'',300000,force),v1('user',['personalstats'],'',300000,force),v1('user',['battlestats'],'',300000,force)]),stats={level:Number(b.level||0),networth:Number(n.networth||0),racing:Number(p.personalstats?.racingskill||0),attacks:Number(p.personalstats?.attackswon||0),offences:Number(p.personalstats?.criminaloffenses||0),battlestats:Object.keys(GYM_CONST).reduce((s,k)=>s+Number(bs[k]||0),0)},goals=GM_getValue(GOALS_STORE,[])||[],rows=goals.map((g,i)=>{const cur=Number(stats[g.type]||0),target=Number(g.target||0),progress=target?Math.min(100,cur/target*100):0;return [esc(g.label||g.type),cur.toLocaleString(),target.toLocaleString(),`${progress.toFixed(1)}%<div class="bar"><i style="width:${progress}%"></i></div>`,`<button class="btn danger" data-goal-remove="${i}">Remove</button>`]});return {html:`${hero('Account Goals','Personal targets saved only in Tampermonkey.')}${section('Add a goal',`<div class="toolbar"><select class="field" data-goal-type>${Object.keys(stats).map(k=>`<option value="${k}">${esc(k)}</option>`).join('')}</select><input class="field" data-goal-target type="number" min="1" placeholder="Target"><button class="btn primary" data-goal-add>Add goal</button></div>`)}${section('Progress',table(['Goal','Current','Target','Progress',''],rows),'No goals yet.')}`,bind:(main)=>{main.querySelector('[data-goal-add]').onclick=()=>{const type=main.querySelector('[data-goal-type]').value,target=Number(main.querySelector('[data-goal-target]').value);if(!target)return;GM_setValue(GOALS_STORE,[...goals,{type,target,label:type}]);loadPanel('goals')};main.querySelectorAll('[data-goal-remove]').forEach(x=>x.onclick=()=>{goals.splice(Number(x.dataset.goalRemove),1);GM_setValue(GOALS_STORE,goals);loadPanel('goals')})}}}
function statMap(payload){return Object.fromEntries((payload.personalstats||[]).map(x=>[String(x.name).toLowerCase(),{value:Number(x.value||0),timestamp:Number(x.timestamp||0)}]))}
function memberStatus(m){const s=String(m.last_action?.status||'other').toLowerCase();return ['online','idle','offline'].includes(s)?s:'other'}
async function factionHistory(member,force){const cache=GM_getValue(HISTORY_STORE,{})||{},entry=cache[String(member.id)];if(!force&&entry&&Date.now()-Number(entry.at)<21600000)return entry.row;const now=statMap(await v2(`user/${member.id}/personalstats`,{stat:'xantaken,timeplayed,activestreak,bestactivestreak'},21600000,force)),ts=Math.max(0,...Object.values(now).map(x=>x.timestamp));if(!ts||!now.xantaken||!now.timeplayed)throw new Error('Required stats unavailable');const old=statMap(await v2(`user/${member.id}/personalstats`,{stat:'xantaken,timeplayed',timestamp:ts-30*86400},21600000,force)),ots=Math.max(0,...Object.values(old).map(x=>x.timestamp));if(!ots||!old.xantaken||!old.timeplayed)throw new Error('30-day snapshot unavailable');const days=Math.max(1,(ts-ots)/86400),x=Math.max(0,now.xantaken.value-old.xantaken.value),row={id:Number(member.id),available:true,xanax:x,daily:x/days,active:Math.max(0,now.timeplayed.value-old.timeplayed.value),streak:now.activestreak?.value||0,best:now.bestactivestreak?.value||0,days};cache[String(member.id)]={at:Date.now(),row};GM_setValue(HISTORY_STORE,cache);return row}
function factionRows(members){const q=state.memberSearch.toLowerCase(),visible=members.filter(m=>(state.memberFilter==='all'||memberStatus(m)===state.memberFilter)&&(!q||`${m.name} ${m.id} ${m.position}`.toLowerCase().includes(q)));return visible.map(m=>{const h=state.factionStats.get(Number(m.id)),status=memberStatus(m);return [link(`https://www.torn.com/profiles.php?XID=${m.id}`,m.name)+`<div class="muted">[${m.id}]</div>`,Number(m.level||0),esc(m.position||'Member'),`<span class="pill ${status}">${esc(m.last_action?.status||'Unknown')}</span><div class="muted">${esc(m.last_action?.relative||relative(m.last_action?.timestamp))}</div>`,h?.available?`${Number(h.daily||0).toFixed(2)}/day<div class="muted">${Number(h.xanax||0).toLocaleString()} in ${Number(h.days||30).toFixed(0)}d</div>`:h?'Unavailable':'Queued…',h?.available?duration(h.active):h?'Unavailable':'Queued…',h?.available?`${h.streak}d<div class="muted">best ${h.best}d</div>`:h?'Unavailable':'Queued…',esc(m.status?.state||'Okay')]} )}
function updateFaction(main,members){const complete=[...state.factionStats.values()].filter(x=>x.available),loaded=state.factionStats.size,total=members.length,x=complete.reduce((s,a)=>s+Number(a.xanax||0),0),daily=complete.reduce((s,a)=>s+Number(a.daily||0),0),active=complete.reduce((s,a)=>s+Number(a.active||0),0);main.querySelector('[data-faction-summary]').innerHTML=`<div class="metrics">${metric('Members',total)}${metric('30d Xanax',x.toLocaleString(),`${daily.toFixed(2)}/day combined`,'warn')}${metric('30d active time',duration(active),`${complete.length} members`,'good')}${metric('History coverage',`${loaded}/${total}`,`${complete.length} complete`)}</div><div class="callout">${loaded<total?`Loading official history ${loaded}/${total}. Rows fill progressively while Torn's API limit is respected.`:'History complete.'}</div>`;main.querySelector('[data-faction-table]').innerHTML=table(['Member','Lvl','Position','Activity','Xanax/day','Active 30d','Login streak','Status'],factionRows(members))}
async function factionPanel(force,token){const [basic,mp]=await Promise.all([v2('faction/basic',{},60000,force),v2('faction/members',{striptags:'true'},60000,force)]),members=mp.members||[],order={online:0,idle:1,offline:2,other:3};members.sort((a,b)=>order[memberStatus(a)]-order[memberStatus(b)]||Number(b.last_action?.timestamp||0)-Number(a.last_action?.timestamp||0));state.factionStats=new Map();return {html:`${hero(basic.basic?.name||'Faction Activity','Every member, 30-day Xanax, active time and login streak.',link('https://www.torn.com/factions.php?step=your','Faction'))}<div data-faction-summary></div><div class="toolbar" style="margin:10px 0"><button class="btn primary" data-mf="all">All</button><button class="btn" data-mf="online">Online</button><button class="btn" data-mf="idle">Idle</button><button class="btn" data-mf="offline">Offline</button><input class="search" data-ms placeholder="Search member…"></div><div data-faction-table></div>`,bind:(main)=>{main.querySelectorAll('[data-mf]').forEach(x=>x.onclick=()=>{state.memberFilter=x.dataset.mf;main.querySelectorAll('[data-mf]').forEach(y=>y.classList.toggle('primary',y===x));updateFaction(main,members)});main.querySelector('[data-ms]').oninput=e=>{state.memberSearch=e.target.value;updateFaction(main,members)};updateFaction(main,members);(async()=>{for(const m of members){if(token!==state.token)return;let row;try{row=await factionHistory(m,force)}catch(e){row={id:Number(m.id),available:false,error:String(e.message||e)}}state.factionStats.set(Number(m.id),row);if(token===state.token)updateFaction(main,members)}})()}}
}
async function ocPanel(force){const [cr,members]=await Promise.all([v2('faction/crimes',{cat:'all',limit:100},60000,force).catch(error=>({crimes:[],ta_error:String(error?.message||error)})),v2('faction/members',{},60000,force)]),names=Object.fromEntries((members.members||[]).map(x=>[Number(x.id),x.name])),rows=(cr.crimes||[]).map(c=>[esc(c.name||`Crime ${c.id}`),esc(c.status||''),c.ready_at?new Date(c.ready_at*1000).toLocaleString():'—',(c.slots||[]).map(s=>`${esc(s.position_info?.name||s.position||'Slot')}: ${s.user_id?esc(names[s.user_id]||s.user_id):'<span class="warn-text">OPEN</span>'}`).join('<br>'),esc(c.notes||'')]);return `${hero('Organized Crime Adviser','Planning, recruiting, readiness and member assignments.',link('https://www.torn.com/factions.php?step=your&type=1#/tab=crimes','Faction Crimes'))}<div class="metrics">${metric('Crimes',rows.length)}${metric('Members',members.members?.length||0)}${metric('Recruiting',(cr.crimes||[]).filter(x=>String(x.status).toLowerCase()==='recruiting').length,'','warn')}${metric('Ready',(cr.crimes||[]).filter(x=>Number(x.ready_at||0)<=Date.now()/1000&&String(x.status).toLowerCase()==='planning').length,'','good')}</div>${cr.ta_error?callout('Your faction role or API key cannot read organized-crime details. The panel stays available and the member count still loads.','warn'):section('Crime board',table(['Crime','Status','Ready','Slots','Notes'],rows))}`}
async function battlePanel(force){const [basic,bs,ap]=await Promise.all([v1('user',['basic'],'',300000,force),v1('user',['battlestats'],'',30000,force),v2('user/attacks',{limit:100,sort:'DESC'},30000,force)]),me=Number(basic.player_id),total=Object.keys(GYM_CONST).reduce((s,k)=>s+Number(bs[k]||0),0),attacks=ap.attacks||[],wins=attacks.filter(a=>['attacked','mugged','hospitalized','left'].includes(String(a.result||'').toLowerCase())).length,rows=attacks.map(a=>{const outgoing=Number(a.attacker?.id||a.attacker_id)===me,other=outgoing?(a.defender||{}):(a.attacker||{});return [new Date(Number(a.ended||a.timestamp_ended||0)*1000).toLocaleString(),outgoing?'Outgoing':'Incoming',link(`https://www.torn.com/profiles.php?XID=${Number(other.id||0)}`,other.name||`Player ${other.id||0}`),esc(a.result||'Unknown'),Number(a.respect_gain||a.respect||0).toFixed(2),a.chain??'—']});return `${hero('Battle Intel','Your stats and latest attack performance.',link('https://www.torn.com/loader.php?sid=attack','Attack'))}<div class="metrics">${metric('Total battle stats',total.toLocaleString())}${metric('Recent attacks',attacks.length)}${metric('Recent wins',wins,pct(attacks.length?wins/attacks.length*100:0),'good')}${metric('Strongest stat',Object.keys(GYM_CONST).sort((a,b)=>Number(bs[b])-Number(bs[a]))[0]||'—')}</div>${section('Stat distribution',table(['Stat','Value','Share'],Object.keys(GYM_CONST).map(k=>[esc(k),Number(bs[k]||0).toLocaleString(),pct(total?Number(bs[k]||0)/total*100:0)])))}${section('Attack history',table(['Ended','Direction','Opponent','Result','Respect','Chain'],rows))}`}
function flattenLog(value,prefix='',depth=0,out=[]){if(depth>3)return out;if(Array.isArray(value)){if(value.every(x=>x===null||typeof x!=='object'))out.push([prefix,value.slice(0,8).join(', ')]);else value.slice(0,5).forEach((x,i)=>flattenLog(x,`${prefix} ${i+1}`,depth+1,out))}else if(value&&typeof value==='object'){for(const [key,child] of Object.entries(value))flattenLog(child,prefix?`${prefix}.${key}`:key,depth+1,out)}else if(value!==null&&value!==undefined&&prefix)out.push([prefix,value]);return out}
function logDetails(data,params){const skip=/timestamp|nanostamp|hash|uid|log_?id/i,priority=/name|item|amount|quantity|money|cash|price|cost|profit|gain|respect|energy|nerve|happy|points|result|destination|country|location|stat|crime|stock|shares|train/i,fields=[...flattenLog(data),...flattenLog(params,'params')].filter(([k])=>!skip.test(k)).sort((a,b)=>Number(priority.test(b[0]))-Number(priority.test(a[0]))),seen=new Set(),shown=[];for(const [key,value] of fields){const label=key.split('.').pop().replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase()),text=typeof value==='number'?value.toLocaleString():String(value),mark=`${label}:${text}`;if(seen.has(mark))continue;seen.add(mark);shown.push(`<span class="log-detail"><b>${esc(label)}</b>${esc(text.length>120?text.slice(0,117)+'…':text)}</span>`);if(shown.length>=8)break}return `<div class="log-details">${shown.join('')||'<span class="muted">No extra details</span>'}</div>`}
async function logsPanel(force){let rows=[],coverage='Full log';try{const p=await v2('user/log',{limit:100},30000,force);rows=(p.log||p.logs||[]).map(x=>{const info=x.details||{};return [new Date(Number(x.timestamp||x.time||0)*1000).toLocaleString(),esc(info.title||x.title||x.type||'Activity'),esc(info.category||x.category||x.category_name||'Other'),logDetails(x.data||{},x.params||{})]})}catch(error){coverage='Limited fallback';const [a,e]=await Promise.all([v2('user/attacks',{limit:50,sort:'DESC'},30000,force),v2('user/events',{limit:50},30000,force)]);rows=[...(a.attacks||[]).map(x=>[new Date(Number(x.ended||x.timestamp_ended||0)*1000).toLocaleString(),'Attack',esc(x.result||''),`<div class="log-details"><span class="log-detail"><b>Respect</b>${Number(x.respect_gain||0).toFixed(2)}</span></div>`]),...(e.events||[]).map(x=>[new Date(Number(x.timestamp||0)*1000).toLocaleString(),'Event','',`<div class="log-details"><span class="log-detail">${esc(x.text||x.event||'')}</span></div>`])].sort((a,b)=>String(b[0]).localeCompare(String(a[0])))}return `${hero('Activity Logs','Latest account activity; Full key adds granular logs.')}${section('Timeline',table(['Time','Activity','Category','Details'],rows),coverage)}`}
const LOADERS={home:homePanel,status:statusPanel,travel:travelPanel,market:marketPanel,stocks:stocksPanel,education:educationPanel,gym:gymPanel,crimes:crimesPanel,racing:racingPanel,casino:casinoPanel,bazaar:bazaarPanel,awards:awardsPanel,goals:goalsPanel,faction:factionPanel,oc:ocPanel,battle:battlePanel,logs:logsPanel};
if(typeof document==='undefined')return;
mount();
if(typeof GM_registerMenuCommand==='function'){
GM_registerMenuCommand('Open Torn Advisor',open);
GM_registerMenuCommand('Torn Advisor Settings',renderSettings);
GM_registerMenuCommand('Clear Torn Advisor cache',()=>{clearCache();GM_deleteValue(HISTORY_STORE)});
}
}());