💫 Points Maker (Optimized + Abroad Color + Flight & Restock)

Torn display + abroad stock with flight selection, restock timer. Collapsible, color-coded abroad, lowest items based on display.

2025-11-26 기준 버전입니다. 최신 버전을 확인하세요.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         💫 Points Maker (Optimized + Abroad Color + Flight & Restock)
// @namespace    http://tampermonkey.net/
// @version      1.2.7
// @description  Torn display + abroad stock with flight selection, restock timer. Collapsible, color-coded abroad, lowest items based on display.
// @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';

    const PANEL_ID = 'points_maker_pda';
    const POLL_INTERVAL_MS = 45 * 1000;
    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_TIMERS = {
        Camel: 22, Panda: 18, Peony: 112, Tribulus: 107, Lion: 19, Violet: 98,
        Jaguar: 27, Cherry: 121, Monkey: 23, Heather: 129, Ceibo: 129, 'Red Fox': 28,
        Nessie: 23, Stingray: 22, Banana: 95, Orchid: 118, Crocus: 114, Chaimos: 21,
        Dahlia: 113, Edelweiss: 100
    };

    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'
    };

    // UTILS
    function escapeHtml(s){if(s==null)return'';return String(s).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
    function colorForPercent(value,max){if(!max||max===0)return'#bdbdbd';const pct=(value/max)*100;if(pct>=75)return'#00c853';if(pct>=40)return'#3399ff';return'#ff1744';}
    
    // UI
    let statusEl, summaryEl, contentEl, flightSelectEl;

    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>
                <select id="tc_flight_select">
                    <option value="Standard">Standard</option>
                    <option value="Airstrip">Airstrip</option>
                    <option value="WLT">WLT</option>
                    <option value="Business">Business</option>
                </select>
            </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);
        statusEl=root.querySelector('#tc_status');
        summaryEl=root.querySelector('#tc_summary');
        contentEl=root.querySelector('#tc_content');
        flightSelectEl=root.querySelector('#tc_flight_select');

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

    let apiKey=GM_getValue('tornAPIKey',null);
    function askKey(force=false){
        if(!force && apiKey)return;
        const key=window.prompt('Enter your Torn API Key:',apiKey||'');
        if(key && key.trim().length>0){apiKey=key.trim(); GM_setValue('tornAPIKey',apiKey); if(statusEl)statusEl.textContent='API key saved. Ready.'; refreshAll(true);}
        else if(statusEl)statusEl.textContent='No key entered.';
    }

    buildUI();

    // Minimal Torn fetch + DOM fallback
    async function fetchTornDisplayInventory(){
        if(!apiKey)return {};
        try{
            const res=await new Promise((resolve,reject)=>{
                GM_xmlhttpRequest({method:'GET',url:`https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(apiKey)}`,onload:r=>{resolve(JSON.parse(r.responseText))},onerror:e=>reject(e),ontimeout:()=>reject('timeout')});
            });
            const items={};
            ['display','inventory'].forEach(sel=>{
                if(res[sel]) Object.values(res[sel]).forEach(e=>{if(!e||!e.name)return;items[e.name]=(items[e.name]||0)+Number(e.quantity||e.qty||1);});
            });
            return items;
        }catch(e){console.warn(e);return{};}
    }

    function fetchDisplayViaDOM(){
        const map={};
        document.querySelectorAll('.display-item, .display_case_item, .dcItem, .item-wrap .item').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;
    }

    // Yata / Prom parsing
    async function gmGetJson(url){return new Promise((resolve,reject)=>{GM_xmlhttpRequest({method:'GET',url,timeout:14000,onload:r=>{try{resolve(JSON.parse(r.responseText))}catch(e){reject(e)}},onerror:e=>reject(e),ontimeout:()=>reject('timeout')});});}

    function parseYata(yataData){
        const map={};
        if(!yataData||!yataData.stocks)return map;
        for(const [c,obj] of Object.entries(yataData.stocks)){
            if(!obj||!obj.stocks)continue;
            const m={};
            for(const it of obj.stocks) if(it&&it.name)m[it.name]=Number(it.quantity||it.qty||0);
            map[c.toUpperCase()]=m;
        }
        return map;
    }

    function parseProm(promData){
        const map={};
        if(!promData)return map;
        for(const [k,v] of Object.entries(promData)){
            if(!v)continue;
            let code=COUNTRY_NAME_TO_CODE[k.toUpperCase()]||k.toUpperCase();
            const m={};
            if(Array.isArray(v.stocks)) for(const it of v.stocks) if(it&&it.name)m[it.name]=Number(it.quantity||it.qty||0);
            map[code]=m;
        }
        return map;
    }

    function sumAcrossCountriesFor(itemName,map){if(!map)return 0;let total=0;for(const c of Object.keys(map))total+=Number(map[c][itemName]||0);return total;}

    // Counts
    function countsForReq(itemsAgg,req,mapObj){
        const counts={};
        req.shortNames.forEach(s=>counts[s]=0);
        req.fullNames.forEach(fn=>{const s=mapObj[fn].short; counts[s]=(counts[s]||0)+(itemsAgg[fn]||0);});
        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 findLowestDisplayOnly(remainder){
        const keys=Object.keys(remainder); if(!keys.length)return null;
        let min=Infinity, key=null;
        keys.forEach(k=>{if(remainder[k]<min){min=remainder[k]; key=k;}});
        return key?{short:key, rem:remainder[key]}:null;
    }

    async function refreshAll(force=false){
        if(force===false && isRefreshing)return;
        isRefreshing=true;
        if(statusEl)statusEl.textContent='Fetching...';
        try{
            const tornPromise=fetchTornDisplayInventory().catch(()=>null);
            const yataPromise=gmGetJson(YATA_URL).catch(()=>null);
            const promPromise=gmGetJson(PROM_URL).catch(()=>null);
            let [displayFromApi,yataRaw,promRaw]=await Promise.all([tornPromise,yataPromise,promPromise]);
            displayFromApi=displayFromApi||fetchDisplayViaDOM();
            renderUI(displayFromApi,yataRaw,promRaw);
            if(statusEl)statusEl.textContent=`Updated: ${new Date().toLocaleTimeString()}`;
        }catch(e){console.warn(e); if(statusEl)statusEl.textContent='Update failed';}
        finally{isRefreshing=false; if(pollTimer!==null)pollTimer=setTimeout(()=>refreshAll(false),POLL_INTERVAL_MS);}
    }

    function renderUI(itemsAgg,yataRaw,promRaw){
        if(!contentEl)return;
        const flowerTotals=countsForReq(itemsAgg,{fullNames:Object.keys(FLOWERS),shortNames:Object.keys(FLOWERS).map(fn=>FLOWERS[fn].short)},FLOWERS);
        const plushTotals=countsForReq(itemsAgg,{fullNames:Object.keys(PLUSHIES),shortNames:Object.keys(PLUSHIES).map(fn=>PLUSHIES[fn].short)},PLUSHIES);
        const fCalc=calcSetsAndRemainderFromCounts(flowerTotals,Object.keys(FLOWERS).map(fn=>FLOWERS[fn].short));
        const pCalc=calcSetsAndRemainderFromCounts(plushTotals,Object.keys(PLUSHIES).map(fn=>PLUSHIES[fn].short));
        let html='';
        const lowFlower=findLowestDisplayOnly(fCalc.remainder);
        const lowPlush=findLowestDisplayOnly(pCalc.remainder);
        if(lowFlower) html+=`<div class="low-line">🛫 Low on ${escapeHtml(lowFlower.short)} — check display case 🛬</div>`;
        if(lowPlush) html+=`<div class="low-line">🛫 Low on ${escapeHtml(lowPlush.short)} — check display case 🛬</div>`;
        html+='<div class="group-title">Flowers</div><ul class="item-list">';
        Object.keys(FLOWERS).forEach(fn=>{
            const short=FLOWERS[fn].short;
            const total=fCalc.remainder[short]||0;
            html+=`<li class="item-row"><span class="item-name">${escapeHtml(short)}</span><span class="item-total">${total}</span></li>`;
        });
        html+='</ul><div class="group-title">Plushies</div><ul class="item-list">';
        Object.keys(PLUSHIES).forEach(fn=>{
            const short=PLUSHIES[fn].short;
            const total=pCalc.remainder[short]||0;
            html+=`<li class="item-row"><span class="item-name">${escapeHtml(short)}</span><span class="item-total">${total}</span></li>`;
        });
        html+='</ul>';
        contentEl.innerHTML=html;
    }

    let pollTimer=null,isRefreshing=false;
    function startPolling(){if(pollTimer)return; pollTimer=setTimeout(()=>refreshAll(true),200);}
    function stopPolling(){if(!pollTimer)return; clearTimeout(pollTimer); pollTimer=null;}

    if(apiKey){ startPolling(); refreshAll(true); }

})();