🌺🧸 Unified Stock (Compact Inline)

Inline one-line stock panel between Torn navs, compact width, fly suggestion, sets & points. Refresh 45s.

Pada tanggal 11 Oktober 2025. Lihat %(latest_version_link).

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         🌺🧸 Unified Stock (Compact Inline)
// @namespace    http://tampermonkey.net/
// @version      4.1.4
// @description  Inline one-line stock panel between Torn navs, compact width, fly suggestion, sets & points. Refresh 45s.
// @author       Nova
// @match        https://www.torn.com/page.php?sid=travel*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      yata.yt
// @connect      api.torn.com
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';
    if (!/page\.php\?sid=travel/.test(location.href)) return;

    const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
    const REFRESH_MS = 45*1000;
    const LOW_STOCK = 500;
    const POINTS_PER_SET = 10;
    const PANEL_WIDTH = 250;

    const FLOWERS_ORDER = [["Dahlia",{code:'mex',loc:'MX 🇲🇽'}],["Orchid",{code:'haw',loc:'HW 🏝️'}],["African Violet",{code:'sou',loc:'SA 🇿🇦'}],["Cherry Blossom",{code:'jap',loc:'JP 🇯🇵'}],["Peony",{code:'chi',loc:'CN 🇨🇳'}],["Ceibo Flower",{code:'arg',loc:'AR 🇦🇷'}],["Edelweiss",{code:'swi',loc:'CH 🇨🇭'}],["Crocus",{code:'can',loc:'CA 🇨🇦'}],["Heather",{code:'uni',loc:'UK 🇬🇧'}],["Tribulus Omanense",{code:'uae',loc:'AE 🇦🇪'}],["Banana Orchid",{code:'cay',loc:'KY 🇰🇾'}]];
    const PLUSHIES_ORDER = [["Sheep Plushie",{code:null,loc:'B.B 🏪'}],["Teddy Bear Plushie",{code:null,loc:'B.B 🏪'}],["Kitten Plushie",{code:null,loc:'B.B 🏪'}],["Jaguar Plushie",{code:'mex',loc:'MX 🇲🇽'}],["Wolverine Plushie",{code:'can',loc:'CA 🇨🇦'}],["Nessie Plushie",{code:'uni',loc:'UK 🇬🇧'}],["Red Fox Plushie",{code:'uni',loc:'UK 🇬🇧'}],["Monkey Plushie",{code:'arg',loc:'AR 🇦🇷'}],["Chamois Plushie",{code:'swi',loc:'CH 🇨🇭'}],["Panda Plushie",{code:'chi',loc:'CN 🇨🇳'}],["Lion Plushie",{code:'sou',loc:'SA 🇿🇦'}],["Camel Plushie",{code:'uae',loc:'AE 🇦🇪'}],["Stingray Plushie",{code:'cay',loc:'KY 🇰🇾'}]];

    const SPECIAL_DRUG = "Xanax";
    const TRACKED_LIST = [...FLOWERS_ORDER.map(x=>x[0]),...PLUSHIES_ORDER.map(x=>x[0]),SPECIAL_DRUG];
    const COUNTRY_NAMES = { mex:'Mexico', cay:'Cayman Islands', can:'Canada', haw:'Hawaii', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', jap:'Japan', chi:'China', uae:'UAE', sou:'South Africa' };

    GM_addStyle(`
        #uniStockPanel{display:inline-block;width:${PANEL_WIDTH}px;background:#0b0b0b;color:#eaeaea;font-family:"DejaVu Sans Mono",monospace;font-size:11px;border:1px solid #444;border-radius:6px;max-height:200px;overflow-y:auto;line-height:1.15;}
        #uniHeader{background:#121212;padding:4px 6px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #333;user-select:none;}
        #uniHeaderLeft{display:flex;flex-direction:column;gap:1px;}
        #titleRow{font-weight:700;font-size:12px;cursor:pointer;}
        #pointsRow{color:#bfc9d6;font-size:10px;}
        .uni-row{display:flex;justify-content:space-between;align-items:center;gap:4px;padding:2px 0;white-space:nowrap;border-bottom:1px solid rgba(255,255,255,0.02);}
        .uni-left{display:flex;align-items:center;gap:4px;min-width:0;overflow:hidden;}
        .dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex:0 0 8px;}
        .g{background:#00c853;}.y{background:#ffb300;}.r{background:#ff1744;}
        .itemname{min-width:0;overflow:hidden;text-overflow:ellipsis;}
        .meta{color:#bfc9d6;width:130px;text-align:right;font-size:10px;flex:0 0 130px;}
        #uni_status{color:#9ea6b3;margin:4px 0;font-size:10px;}
        .fly{color:#9ad0ff;font-weight:700;margin-left:4px;}
        .small{font-size:10px;color:#9ea6b3;margin-top:4px;}
        .pts-btn{background:#171717;color:#eaeaea;border:1px solid #333;padding:2px 4px;border-radius:4px;cursor:pointer;font-size:10px;}
    `);

    const leftNav = document.querySelector('.nav-left');
    const rightNav = document.querySelector('.nav-right');
    const panel=document.createElement('div');
    panel.id='uniStockPanel';
    panel.innerHTML=`
        <div id="uniHeader">
            <div id="uniHeaderLeft">
                <div id="titleRow">▶ 🌺🧸 Unified Stock</div>
                <div id="pointsRow">Sets: - | Points: -</div>
            </div>
            <div style="display:flex;gap:4px;align-items:center">
                <button id="uniRefresh" class="pts-btn">Refresh</button>
                <button id="uniSetKey" class="pts-btn">Set Key</button>
            </div>
        </div>
        <div id="uniContent">
            <div id="uni_status">Initializing...</div>
            <div id="uniList"></div>
            <div class="small">Format: Item — (inv: X | stk: Y) · lowest item shows "✈ Fly to"</div>
        </div>
    `;
    if(leftNav && rightNav && leftNav.parentNode===rightNav.parentNode){ leftNav.parentNode.insertBefore(panel,rightNav); }
    else{ document.body.appendChild(panel); }

    const titleRow=panel.querySelector('#titleRow');
    const pointsRow=panel.querySelector('#pointsRow');
    const statusEl=panel.querySelector('#uni_status');
    const listEl=panel.querySelector('#uniList');
    const btnRefresh=panel.querySelector('#uniRefresh');
    const btnSetKey=panel.querySelector('#uniSetKey');

    let collapsed=GM_getValue('uni_collapsed',false);
    function updateCollapse(){ panel.querySelector('#uniContent').style.display=collapsed?'none':'block'; titleRow.textContent=(collapsed?'▶':'▼')+' 🌺🧸 Unified Stock'; GM_setValue('uni_collapsed',collapsed); }
    titleRow.addEventListener('click',()=>{ collapsed=!collapsed; updateCollapse(); });
    updateCollapse();

    let apiKey=GM_getValue('tornAPIKey',null);
    btnSetKey.addEventListener('click',()=>{ const k=prompt('Enter Torn API key (display permission):',apiKey||''); if(k){apiKey=k.trim(); GM_setValue('tornAPIKey',apiKey); statusEl.textContent='API key saved.'; refreshAll(true);} });
    btnRefresh.addEventListener('click',()=>refreshAll(true));

    function gmGetJson(url){ return new Promise((resolve,reject)=>{ GM_xmlhttpRequest({ method:'GET',url,responseType:'json',onload:(res)=>{ let d=res.response; if(!d&&res.responseText){ try{d=JSON.parse(res.responseText);}catch(e){return reject(new Error('Invalid JSON')); } } resolve(d);}, onerror:(err)=>reject(err),ontimeout:()=>reject(new Error('timeout')) }); }); }

    async function fetchDisplayCaseSafe(){ if(!apiKey)return{}; try{ const data=await gmGetJson(`https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`); if(!data||data.error)return{}; const out={}; const entries=data.display?Array.isArray(data.display)?data.display:Object.values(data.display):[]; for(const e of entries){ if(!e)continue; const name=e.name||e.item_name||e.title||e.item; const qty=Number(e.quantity??e.qty??e.amount??0)||0; if(!name)continue; out[name]=(out[name]||0)+qty;} return out;}catch(e){console.warn('display fetch error',e); return{};} }

    async function fetchYataSafe(){ try{ const data=await gmGetJson(YATA_URL); return data||null;}catch(e){console.warn('YATA fetch error',e); return null;} }

    function computeSets(displayMap){ const flowerCounts=FLOWERS_ORDER.map(([name])=>Number(displayMap[name]||0)); const plushCounts=PLUSHIES_ORDER.map(([name])=>Number(displayMap[name]||0)); const fSets=flowerCounts.length?Math.min(...flowerCounts):0; const pSets=plushCounts.length?Math.min(...plushCounts):0; const totalSets=(isFinite(fSets)?fSets:0)+(isFinite(pSets)?pSets:0); const points=totalSets*POINTS_PER_SET; return {totalSets,points}; }

    function dotClass(inv,stk){ if((stk>0&&stk<LOW_STOCK)||(inv>0&&stk>0&&stk<LOW_STOCK))return'y'; if(inv>0||stk>0)return'g'; return'r'; }
    function escapeHtml(s){if(s===null||s===undefined)return'';return String(s).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
    function getFlagForCode(code){ if(!code)return''; const map={ mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦'}; return map[code]||''; }

    async function refreshAll(force=false){
        statusEl.textContent='Updating...';
        try{
            const [displayMap,yataData]=await Promise.all([fetchDisplayCaseSafe(),fetchYataSafe()]);
            renderAll(displayMap||{},yataData||null);
            statusEl.textContent=`Updated: ${new Date().toLocaleTimeString()}`;
        }catch(e){ console.warn('refreshAll error',e); statusEl.textContent='Update error';}
    }

    function buildUnified(displayMap,yataData){
        const yataStocks={};
        if(yataData?.stocks){ for(const [code,obj] of Object.entries(yataData.stocks)){ yataStocks[code]={}; for(const it of obj.stocks||[]){ if(!it?.name)continue; yataStocks[code][it.name]=Number(it.quantity||0);}}}
        const itemsInfo=[];
        for(const name of TRACKED_LIST){
            if(name===SPECIAL_DRUG){ const stk=yataStocks['sou']?.[SPECIAL_DRUG]||0; const inv=Number(displayMap[name]||0); itemsInfo.push({name,inv,totalStk:stk,bestCode:stk>0?'sou':null,bestStk:stk,loc:'SA 🇿🇦'}); continue; }
            let total=0,bestCode=null,bestStk=0;
            for(const [code,map] of Object.entries(yataStocks)){ const q=Number(map[name]||0)||0; total+=q; if(q>bestStk){bestStk=q; bestCode=code;} }
            let loc=''; const f=FLOWERS_ORDER.find(x=>x[0]===name); const p=PLUSHIES_ORDER.find(x=>x[0]===name);
            if(f)loc=f[1].loc; else if(p)loc=p[1].loc;
            const inv=Number(displayMap[name]||0)||0;
            itemsInfo.push({name,inv,totalStk:total,bestCode,bestStk,loc});
        }
        let lowest=null;
        for(const it of itemsInfo){ if(!lowest){lowest=it;continue;} if((it.inv||0)<(lowest.inv||0))lowest=it; else if((it.inv||0)===(lowest.inv||0)&&(it.totalStk||0)<(lowest.totalStk||0))lowest=it; }
        return {itemsInfo,lowest,yataStocks};
    }

    function renderAll(displayMap,yataData){
        const {totalSets,points}=computeSets(displayMap);
        pointsRow.textContent=`Sets: ${totalSets} | Points: ${points}`;
        const {itemsInfo,lowest}=buildUnified(displayMap,yataData);
        const order=[...FLOWERS_ORDER.map(x=>x[0]),...PLUSHIES_ORDER.map(x=>x[0]),SPECIAL_DRUG];
        const infoByName={}; for(const it of itemsInfo)infoByName[it.name]=it;
        let html='';
        for(const name of order){
            const it=infoByName[name]; if(!it)continue; const inv=Number(it.inv||0); const stk=Number(it.totalStk||0); const cls=dotClass(inv,stk);
            const flyNote=(lowest&&name===lowest.name&&lowest.bestCode&&(lowest.bestStk>0))?` <span class="fly">✈ Fly to: ${(COUNTRY_NAMES[lowest.bestCode]||lowest.bestCode.toUpperCase())} ${getFlagForCode(lowest.bestCode)}</span>`:'';
            const meta=`(inv: ${inv} | stk: ${stk})${flyNote}`;
            html+=`<div class="uni-row"><div class="uni-left"><span class="dot ${cls}"></span><div class="itemname">${escapeHtml(name)}</div></div><div class="meta">${meta}</div></div>`;
        }
        listEl.innerHTML=html||`<div style="color:#999;">No tracked items found.</div>`;
    }

    refreshAll(true); setInterval(()=>refreshAll(false),REFRESH_MS);
})();