💫 Points Maker (Full + Abroad + Restock Check)

Shows Torn display, abroad stock, restock vs flight, next-lowest suggestions, color-coded.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         💫 Points Maker (Full + Abroad + Restock Check)
// @namespace    http://tampermonkey.net/
// @version      1.3.w
// @description  Shows Torn display, abroad stock, restock vs flight, next-lowest suggestions, color-coded.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    // CONFIG
    const PANEL_ID = 'points_maker_pda';
    const POLL_INTERVAL_MS = 45000;
    const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
    const PROM_URL = 'https://api.prombot.co.uk/api/travel';
    const SPECIAL_DRUG = 'Xanax';
    const MAX_ABROAD = { flowers: 50, plushies: 50, drugs: 50 };

    const RESTOCK_TIMES = { Camel: 22, Panda: 18, Peony: 112, Tribulus: 107, Lion: 19, Violet: 98,
                            Jaguar: 27, Cherry: 121, Monkey: 23, Heather: 129, Ceibo: 129, RedFox: 28,
                            Nessie: 23, Stingray: 22, Banana: 95, Orchid: 118, Crocus: 114,
                            Chamois: 21, Dahlia: 113, Edelweiss: 100 };

    const BUSINESS_FLIGHT = { Mexico:8, Cayman:11, Canada:12, Hawaii:40, UK:48, Argentina:50,
                              Switzerland:53, Japan:68, China:72, UAE:81, 'South Africa':89 };

    const FLOWERS = { "Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico" },
                      "Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii" },
                      "African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa" },
                      "Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan" },
                      "Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China" },
                      "Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina" },
                      "Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland" },
                      "Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada" },
                      "Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom" },
                      "Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE" },
                      "Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands" } };

    const PLUSHIES = { "Sheep Plushie": { short: "Sheep", loc: "B.B 🏪", country: "Torn City" },
                       "Teddy Bear Plushie": { short: "Teddy", loc: "B.B 🏪", country: "Torn City" },
                       "Kitten Plushie": { short: "Kitten", loc: "B.B 🏪", country: "Torn City" },
                       "Jaguar Plushie": { short: "Jaguar", loc: "MX 🇲🇽", country: "Mexico" },
                       "Wolverine Plushie": { short: "Wolverine", loc: "CA 🇨🇦", country: "Canada" },
                       "Nessie Plushie": { short: "Nessie", loc: "UK 🇬🇧", country: "United Kingdom" },
                       "Red Fox Plushie": { short: "Fox", loc: "UK 🇬🇧", country: "United Kingdom" },
                       "Monkey Plushie": { short: "Monkey", loc: "AR 🇦🇷", country: "Argentina" },
                       "Chamois Plushie": { short: "Chamois", loc: "CH 🇨🇭", country: "Switzerland" },
                       "Panda Plushie": { short: "Panda", loc: "CN 🇨🇳", country: "China" },
                       "Lion Plushie": { short: "Lion", loc: "SA 🇿🇦", country: "South Africa" },
                       "Camel Plushie": { short: "Camel", loc: "AE 🇦🇪", country: "UAE" },
                       "Stingray Plushie": { short: "Stingray", loc: "KY 🇰🇾", country: "Cayman Islands" } };

    const COUNTRY_NAME_TO_CODE = {
        'JAPAN':'JAP','MEXICO':'MEX','CANADA':'CAN','CHINA':'CHI','UNITED KINGDOM':'UNI',
        'ARGENTINA':'ARG','SWITZERLAND':'SWI','HAWAII':'HAW','UAE':'UAE','CAYMAN ISLANDS':'CAY',
        'SOUTH AFRICA':'SOU','S.A':'SOU','SA':'SOU','TORN':'BB','B.B':'BB'
    };

    // UTIL
    function escapeHtml(s){return s?String(s).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])):'';}
    function colorForPercent(val,max){if(!max||max===0)return'#bdbdbd'; const p=(val/max)*100; return p>=75?'#00c853':p>=40?'#3399ff':'#ff1744';}
    function stockClassByQty(q){q=Number(q||0);return q===0?'stock-red':q>=1500?'stock-green':q>=321&&q<=749?'stock-orange':'stock-gray';}

    function buildRequiredList(mapObj){
        const fullNames=Object.keys(mapObj), shortNames=[], locByShort={}, countryByShort={};
        fullNames.forEach(fn=>{const s=mapObj[fn].short; shortNames.push(s); locByShort[s]=mapObj[fn].loc; countryByShort[s]=mapObj[fn].country;});
        return {fullNames, shortNames, locByShort, countryByShort};
    }
    const flowersReq = buildRequiredList(FLOWERS);
    const plushReq = buildRequiredList(PLUSHIES);

    // UI
    let statusEl, summaryEl, contentEl;
    function buildUI(){
        if(document.getElementById(PANEL_ID))return;
        const root=document.createElement('div');root.id=PANEL_ID;root.innerHTML=`
        <div id="tc_header" class="header">▶ 💫 Points Maker</div>
        <div id="tc_content_wrapper">
            <div id="tc_controls" class="controls">
                <button id="tc_refresh">Refresh</button>
                <button id="tc_setkey">Set API Key</button>
                <button id="tc_resetkey">Reset Key</button>
            </div>
            <div id="tc_status">Waiting for API key...</div>
            <div id="tc_summary"></div>
            <div id="tc_content"></div>
        </div>`; document.body.appendChild(root);

        const headerEl=root.querySelector('#tc_header');
        const contentWrapper=root.querySelector('#tc_content_wrapper');
        let collapsed=GM_getValue(`${PANEL_ID}-collapsed`,false);
        function updateCollapse(){headerEl.textContent=(collapsed?'▶ ':'▼ ')+'💫 Points Maker'; contentWrapper.style.display=collapsed?'none':'block';}
        updateCollapse();
        headerEl.addEventListener('click',()=>{collapsed=!collapsed;GM_setValue(`${PANEL_ID}-collapsed`,collapsed);updateCollapse();});
        root.querySelector('#tc_refresh').addEventListener('click',()=>refreshAll(true));
        root.querySelector('#tc_setkey').addEventListener('click',()=>askKey(true));
        root.querySelector('#tc_resetkey').addEventListener('click',()=>{GM_setValue('tornAPIKey',null);apiKey=null;statusEl.textContent='Key cleared. Click Set API Key.';summaryEl.innerHTML='';contentEl.innerHTML='';stopPolling();});
        statusEl=root.querySelector('#tc_status'); summaryEl=root.querySelector('#tc_summary'); contentEl=root.querySelector('#tc_content');
    }

    GM_addStyle(`
    #${PANEL_ID}{position:fixed;top:42px;left:100px;width:250px;background:#0b0b0b;color:#eaeaea;font-family:"DejaVu Sans Mono",monospace;font-size:9px;border:1px solid #444;border-radius:6px;z-index:999999;box-shadow:0 6px 16px rgba(0,0,0,0.5);max-height:65vh;overflow-y:auto;line-height:1.1;}
    #${PANEL_ID} .header{background:#121212;padding:4px 6px;cursor:pointer;font-weight:700;font-size:10px;border-bottom:1px solid #333;user-select:none;display:flex;align-items:center;gap:6px;}
    #${PANEL_ID} .controls{padding:6px;display:flex;gap:6px;}
    #${PANEL_ID} .controls button{font-size:9px;padding:2px 6px;background:#171717;color:#eaeaea;border:1px solid #333;border-radius:3px;cursor:pointer;}
    #${PANEL_ID} .controls button:hover{background:#222;}
    #${PANEL_ID} .summary-line{font-weight:700;margin:6px;font-size:10px;color:#dfe7ff;}
    #${PANEL_ID} .low-line{color:#ff4d4d;font-weight:700;margin:6px;font-size:10px;}
    #${PANEL_ID} .group-title{font-weight:700;margin:6px 6px 2px 6px;font-size:9.5px;}
    #${PANEL_ID} ul.item-list{margin:4px 6px 8px 12px;padding:0;list-style:none;}
    #${PANEL_ID} li.item-row{display:flex;align-items:center;gap:6px;padding:2px 0;white-space:nowrap;}
    #${PANEL_ID} .item-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;}
    #${PANEL_ID} .item-total{flex:0 0 36px;text-align:right;color:#cfe8c6;}
    #${PANEL_ID} .item-av{flex:0 0 60px;text-align:right;color:#f7b3b3;}
    #${PANEL_ID} .item-loc{flex:0 0 36px;text-align:right;color:#bcbcbc;font-size:8.5px;}
    #${PANEL_ID} #tc_status{font-size:9px;color:#bdbdbd;margin:6px;}
    .stock-green{color:#00c853!important;}.stock-orange{color:#ff9800!important;}.stock-red{color:#ff1744!important;}.stock-gray{color:#9ea6b3!important;}
    `);

    buildUI();

    // NETWORK
    function gmGetJson(url,timeout=14000){return new Promise((resolve,reject)=>{try{GM_xmlhttpRequest({method:'GET',url,timeout,onload:res=>{try{const txt=typeof res.response==='string'?res.response:res.responseText;const parsed=txt&&txt.length?JSON.parse(txt):res.response;resolve(parsed);}catch(e){reject(e);}},onerror:err=>reject(err),ontimeout:()=>reject(new Error('timeout'))});}catch(e){reject(e);}});}

    // DATA PARSERS
    function aggregateFromApiResponse(data){const items={};const push=src=>{if(!src)return;const entries=Array.isArray(src)?src:Object.values(src);for(const e of entries){if(!e)continue;const name=e.name||e.item_name||e.title||e.item||null;if(!name)continue;const qty=Number(e.quantity??e.qty??e.amount??1)||0;items[name]=(items[name]||0)+qty;}};push(data.display);push(data.inventory);return items;}
    async function fetchTornDisplayInventory(){const key=GM_getValue('tornAPIKey',null);if(!key)return null;const url=`https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;try{const data=await gmGetJson(url);if(!data||data.error)return null;return aggregateFromApiResponse(data);}catch(e){console.warn('fetchTornDisplayInventory',e);return null;}}
    function fetchDisplayViaDOM(){const map={};const els=document.querySelectorAll('.display-item, .display_case_item, .dcItem, .item-wrap .item');if(els&&els.length){els.forEach(el=>{let name='';let qty=0;const nameEl=el.querySelector('.item-name, .name, .title')||el.querySelector('a')||el;if(nameEl)name=(nameEl.innerText||'').trim();const qtyEl=el.querySelector('.item-amount, .count, .qty, .quantity, .item-qty');if(qtyEl)qty=parseInt((qtyEl.innerText||'').replace(/\D/g,''))||0;if(name)map[name]=(map[name]||0)+qty;});}return map;}
    function parseYata(yataData){const map={};if(!yataData||!yataData.stocks)return map;for(const [code,obj] of Object.entries(yataData.stocks)){const c=String(code).toUpperCase();const arr=Array.isArray(obj.stocks)?obj.stocks:[];const m={};for(const it of arr)if(it&&it.name)m[it.name]=Number(it.quantity??it.qty??0)||0;map[c]=m;}return map;}
    function parseProm(promData){const map={};if(!promData)return map;for(const [countryKey,countryVal] of Object.entries(promData)){if(!countryVal)continue;const up=String(countryKey).trim().toUpperCase();let code=COUNTRY_NAME_TO_CODE[up]||up;const m={};if(Array.isArray(countryVal.stocks)){for(const it of countryVal.stocks)if(it&&it.name)m[it.name]=Number(it.quantity??it.qty??0)||0;}else{for(const [k,v] of Object.entries(countryVal)){if(v==null)continue;if(typeof v==='object'&&('quantity'in v||'qty'in v||'amount'in v))m[k]=Number(v.quantity??v.qty??v.amount??0)||0;else if(typeof v==='number'||!isNaN(Number(v)))m[k]=Number(v)||0;else if(typeof v==='object'&&v.stocks&&Array.isArray(v.stocks)){for(const it of v.stocks)if(it&&it.name)m[it.name]=Number(it.quantity??it.qty??0)||0;}}}map[String(code).toUpperCase()]=m;}return map;}

    function sumAcrossCountriesFor(itemName,parsedMap){if(!parsedMap)return 0;let total=0;for(const c of Object.keys(parsedMap))total+=Number(parsedMap[c][itemName]||0);return total;}
    function countsForReq(itemsAgg,req,mapObj){const counts={};req.shortNames.forEach(s=>counts[s]=0);req.fullNames.forEach(fn=>{const short=mapObj[fn].short;const q=itemsAgg[fn]||0;counts[short]=(counts[short]||0)+q;});return counts;}
    function calcSetsAndRemainderFromCounts(counts,shortNames){const arr=shortNames.map(n=>counts[n]||0);const sets=arr.length?Math.min(...arr):0;const remainder={};shortNames.forEach(n=>remainder[n]=Math.max(0,(counts[n]||0)-sets));return {sets,remainder};}
    function findLowestSmart(remainder,locMap,countryMap){const keys=Object.keys(remainder);if(!keys.length)return null;let min=Infinity;keys.forEach(k=>{if(remainder[k]<min)min=remainder[k];});const allEqual=keys.every(k=>remainder[k]===min);if(allEqual)return null;const key=keys.find(k=>remainder[k]===min);return {short:key,rem:min,loc:locMap[key]||'',country:countryMap[key]||''};}

    // UI RENDER function from previous message
    function renderUI(itemsAgg, yataRaw, promRaw){
        if(!contentEl)return;
        const flowerTotals=countsForReq(itemsAgg,flowersReq,FLOWERS);
        const plushTotals=countsForReq(itemsAgg,plushReq,PLUSHIES);
        const fCalc=calcSetsAndRemainderFromCounts(flowerTotals,flowersReq.shortNames);
        const pCalc=calcSetsAndRemainderFromCounts(plushTotals,plushReq.shortNames);
        const yataMap=parseYata(yataRaw);
        const promMap=parseProm(promRaw);
        function pickAvFor(fullName,category){const yataSum=sumAcrossCountriesFor(fullName,yataMap);const promSum=sumAcrossCountriesFor(fullName,promMap);const val=yataSum||promSum||0;const src=yataSum?'Y':promSum?'P':null;const col=colorForPercent(val,MAX_ABROAD[category]||50);return {val,src,color:col};}
        function restockWillHappen(name,flightMinutes){const timeToRestock=RESTOCK_TIMES[name]||99999;return timeToRestock<=flightMinutes;}
        function buildItemRows(req,mapObj,category,flightMinutes){let html='';const remainder=calcSetsAndRemainderFromCounts(countsForReq(itemsAgg,req,mapObj),req.shortNames).remainder;const lowest=findLowestSmart(remainder,req.locByShort,req.countryByShort);req.fullNames.forEach(full=>{const short=mapObj[full].short;const localQty=remainder[short]||0;const abroad=pickAvFor(full,category);const willRestock=restockWillHappen(full,flightMinutes);let displayName=short;let note='';if(lowest&&lowest.short===short&&localQty===0&&abroad.val===0&&!willRestock)note='➡ Next lowest suggested';else if(!willRestock)note='⏱ Restock after flight';html+=`<li class="item-row" style="color:${abroad.color}"><span class="item-name">${escapeHtml(displayName)} ${note}</span><span class="item-total">${localQty}</span><span class="item-av">(${abroad.val} Av [${abroad.src||'—'}])</span><span class="item-loc">${mapObj[full].loc||''}</span></li>`;});return html;}
        if(statusEl)statusEl.textContent=`Updated: ${new Date().toLocaleTimeString()} — Sets F:${fCalc.sets} P:${pCalc.sets}`;
        if(summaryEl)summaryEl.innerHTML=`<div class="summary-line">Total sets: ${fCalc.sets+pCalc.sets} | Points: ${(fCalc.sets+pCalc.sets)*10}</div>`;
        let html='';
        html+=`<div class="group-title">Flowers — sets: ${fCalc.sets} | pts: ${fCalc.sets*10}</div><ul class="item-list">`;
        html+=buildItemRows(flowersReq,FLOWERS,'flowers',0);
        html+=`</ul>`;
        html+=`<div class="group-title">Plushies — sets: ${pCalc.sets} | pts: ${pCalc.sets*10}</div><ul class="item-list">`;
        html+=buildItemRows(plushReq,PLUSHIES,'plushies',0);
        html+=`</ul>`;
        const xanInv=itemsAgg[SPECIAL_DRUG]||0;
        const yataX=sumAcrossCountriesFor(SPECIAL_DRUG,yataMap);
        const promX=sumAcrossCountriesFor(SPECIAL_DRUG,promMap);
        const xanPicked={val:yataX||promX||0,src:yataX?'Y':promX?'P':'—',color:colorForPercent(yataX||promX,MAX_ABROAD.drugs)};
        html+=`<div class="group-title">Drugs</div><ul class="item-list">
                <li class="item-row" style="color:${xanPicked.color}">
                    <span class="item-name">${SPECIAL_DRUG}</span>
                    <span class="item-total">${xanInv}</span>
                    <span class="item-av">(${xanPicked.val} Av [${xanPicked.src}])</span>
                    <span class="item-loc">🇿🇦</span>
                </li>
             </ul>`;
        contentEl.innerHTML=html;
    }

    // API Key
    let apiKey = GM_getValue('tornAPIKey', null);
    async function askKey(force=false){
        if(!apiKey||force){
            const key=prompt('Enter Torn API Key:',apiKey||'');
            if(key){apiKey=key;GM_setValue('tornAPIKey',key);statusEl.textContent='API Key set.';refreshAll(true);}
        }
    }

    // POLLING
    let pollTimer=null;
    function stopPolling(){if(pollTimer){clearTimeout(pollTimer);pollTimer=null;}}
    async function refreshAll(force=false){
        if(!apiKey){statusEl.textContent='Set API key first.';return;}
        statusEl.textContent='Fetching Torn inventory...';
        const itemsAgg=await fetchTornDisplayInventory()||fetchDisplayViaDOM()||{};
        statusEl.textContent='Fetching abroad stock...';
        const [yataData,promData]=await Promise.all([gmGetJson(YATA_URL),gmGetJson(PROM_URL)]);
        renderUI(itemsAgg,yataData,promData);
        if(!force)pollTimer=setTimeout(refreshAll,POLL_INTERVAL_MS);
    }

    // INIT
    buildUI();
    askKey();
    refreshAll();

})();