Torn Advisor Dashboard

A responsive, read-only Torn adviser inside Torn for desktop and mobile.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)});
    }
}());