Torn display + abroad stock with flight selection, restock timer. Collapsible, color-coded abroad, lowest items based on display.
// ==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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); }
})();