Shows Torn display, abroad stock, restock vs flight, next-lowest suggestions, color-coded.
// ==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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
})();