🌺🧸 Unified Stock (Compact Inline)

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

目前為 2025-10-11 提交的版本,檢視 最新版本

// ==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);
})();