// ==UserScript==
// @name 🌺 🐫 Points Exporter
// @namespace http://tampermonkey.net/
// @version 2.9
// @description Compact tracker for flowers and plushies. Short names | remaining(after sets) | (ms X) | CODE + flag. Auto-calculates full sets and points. Bold red low-on lines with travel hint. Uses Torn public API key.
// @author Nova
// @match https://www.torn.com/displaycase.php*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function() {
'use strict';
// Full API item name -> { short: displayName, loc: "CODE 🇳🇿", country: "Country Name" }
const FLOWERS = {
"Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico" },
"Orchid": { short: "Orchid", loc: "US 🇺🇸", country: "United States" }, // Hawaii -> US
"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: "US 🇺🇸", country: "United States" }, // uncertain origin; kept US
"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" }
};
GM_addStyle(`
#setTrackerPanel {
position: fixed;
top: 100px;
left: 18px;
width: 240px;
background: #0b0b0b;
color: #eaeaea;
font-family: "DejaVu Sans Mono", "Liberation Mono", monospace;
font-size: 9px;
border: 1px solid #444;
border-radius: 6px;
z-index: 2147483647;
box-shadow: 0 6px 16px rgba(0,0,0,0.5);
max-height: 65vh;
overflow-y: auto;
line-height: 1.1;
}
#setTrackerHeader {
background: #121212;
padding: 4px 6px;
cursor: pointer;
font-weight: 700;
font-size: 10px;
border-bottom: 1px solid #333;
user-select: none;
}
#setTrackerContent { padding: 5px; display: none; }
#setTrackerPanel .controls { margin-bottom:5px; }
#setTrackerPanel button {
margin: 2px 2px 5px 0;
font-size: 9px;
padding: 2px 5px;
background: #171717;
color: #eaeaea;
border: 1px solid #333;
border-radius: 3px;
cursor: pointer;
}
#setTrackerPanel button:hover { background: #222; }
.summary-line { font-weight:700; margin-bottom:6px; font-size:10px; color:#dfe7ff; }
.low-line { color:#ff4d4d; font-weight:700; margin-bottom:6px; font-size:10px; }
.group-title { font-weight:700; margin-top:4px; margin-bottom:3px; font-size:9.5px; }
ul.item-list { margin:0 0 4px 0; padding:0; list-style:none; }
li.item-row {
display:flex;
align-items:center;
gap:4px;
padding:1px 0;
white-space:nowrap;
}
.item-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
.item-total { flex:0 0 40px; text-align:right; color:#cfe8c6; }
.item-ms { flex:0 0 42px; text-align:right; color:#f7b3b3; }
.item-loc { flex:0 0 56px; text-align:right; color:#bcbcbc; font-size:8.5px; }
#tc_status { font-size:9px; color:#bdbdbd; margin-bottom:5px; }
`);
// Create panel
const panel = document.createElement('div');
panel.id = 'setTrackerPanel';
panel.innerHTML = `
<div id="setTrackerHeader">▶ 🌺 🐫 Points Exporter</div>
<div id="setTrackerContent">
<div 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 key...</div>
<div id="tc_summary"></div>
<div id="tc_content"></div>
</div>
`;
document.body.appendChild(panel);
const headerEl = panel.querySelector('#setTrackerHeader');
const contentBox = panel.querySelector('#setTrackerContent');
headerEl.addEventListener('click', () => {
const open = contentBox.style.display === 'block';
contentBox.style.display = open ? 'none' : 'block';
headerEl.textContent = (open ? '▶' : '▼') + ' 🌺 🐫 Points Exporter';
});
const statusEl = panel.querySelector('#tc_status');
const summaryEl = panel.querySelector('#tc_summary');
const contentEl = panel.querySelector('#tc_content');
panel.querySelector('#tc_refresh').addEventListener('click', () => loadData());
panel.querySelector('#tc_setkey').addEventListener('click', () => askKey(true));
panel.querySelector('#tc_resetkey').addEventListener('click', () => {
GM_setValue('tornAPIKey', null);
apiKey = null;
statusEl.textContent = 'Key cleared. Click Set API Key.';
contentEl.innerHTML = '';
summaryEl.innerHTML = '';
});
let apiKey = GM_getValue('tornAPIKey', null);
async function askKey(force) {
if (!apiKey || force) {
const k = prompt('Enter your Torn PUBLIC API key (public/minimal access):', apiKey || '');
if (k) {
apiKey = k.trim();
GM_setValue('tornAPIKey', apiKey);
}
}
if (apiKey) loadData();
}
// Aggregate display counts from API
function aggregateDisplay(data) {
const items = {};
const displayRaw = data.display || data.displaycase || data.displayCase || null;
if (!displayRaw) return items;
const entries = Array.isArray(displayRaw) ? displayRaw : Object.values(displayRaw);
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;
}
return items;
}
// Map full-name object to arrays of required items (shortName order preserved)
function buildRequiredList(mapObj) {
const fullNames = Object.keys(mapObj);
const shortNames = fullNames.map(fn => mapObj[fn].short);
const locByShort = {};
const countryByShort = {};
fullNames.forEach(fn => {
const s = mapObj[fn].short;
locByShort[s] = mapObj[fn].loc;
countryByShort[s] = mapObj[fn].country;
});
return { fullNames, shortNames, locByShort, countryByShort };
}
const flowersReq = buildRequiredList(FLOWERS);
const plushReq = buildRequiredList(PLUSHIES);
// Build counts for short names using API keys (full names)
function countsForReq(itemsAgg, req) {
const counts = {};
// init
req.shortNames.forEach(s => counts[s] = 0);
// For each full name key, if present in itemsAgg add its count to the corresponding short
Object.keys(req.fullNames || {}).length; // noop to satisfy lint
Object.keys(FLOWERS).forEach(full => {}); // noop - we'll map using req.fullNames below
for (const fullName of req.fullNames) {
const short = req.fullNames ? req.fullNames && (req.fullNames.indexOf(fullName)>=0 ? req.fullNames[req.fullNames.indexOf(fullName)] : null) : null;
// above is unnecessary - use direct mapping: fullName -> short from map object
}
// Better approach: iterate the req.fullNames and map:
req.fullNames.forEach(fn => {
const s = (req === flowersReq) ? FLOWERS[fn].short : PLUSHIES[fn].short;
const q = itemsAgg[fn] || 0;
counts[s] = (counts[s] || 0) + q;
});
return counts;
}
// Compute sets (min across required items) and remainder after deducting sets
function calcSetsAndRemainderFromCounts(counts, shortNames) {
const countsArr = shortNames.map(n => counts[n] || 0);
const sets = countsArr.length ? Math.min(...countsArr) : 0;
const remainder = {};
shortNames.forEach(n => remainder[n] = Math.max(0, (counts[n] || 0) - sets));
return { sets, remainder };
}
// Find lowest remaining item and its info (shortName, remCount, loc, country)
function findLowest(remainder, locMap, countryMap) {
const keys = Object.keys(remainder);
if (keys.length === 0) return null;
let min = Infinity;
keys.forEach(k => { if (remainder[k] < min) min = remainder[k]; });
// if all equal, do not show (per spec)
const allEqual = keys.every(k => remainder[k] === min);
if (allEqual) return null;
// find first key with min
const key = keys.find(k => remainder[k] === min);
return { short: key, rem: min, loc: locMap[key] || '', country: countryMap[key] || '' };
}
// Render UI
function renderUI(itemsAgg) {
// build counts for flowers and plushies
const flowerCounts = countsForReq(itemsAgg, flowersReq);
const plushCounts = countsForReq(itemsAgg, plushReq);
const fCalc = calcSetsAndRemainderFromCounts(flowerCounts, flowersReq.shortNames);
const pCalc = calcSetsAndRemainderFromCounts(plushCounts, plushReq.shortNames);
const totalSets = fCalc.sets + pCalc.sets;
const totalPoints = totalSets * 10;
// Combined summary
summaryEl.innerHTML = `<div class="summary-line">Total sets: ${totalSets} | Points: ${totalPoints}</div>`;
// Find low items
const lowFlower = findLowest(fCalc.remainder, flowersReq.locByShort, flowersReq.countryByShort);
const lowPlush = findLowest(pCalc.remainder, plushReq.locByShort, plushReq.countryByShort);
let html = '';
// Flowers low line
if (lowFlower) {
html += `<div class="low-line">🛫 Low on ${lowFlower.short} — travel to ${lowFlower.country} ${lowFlower.loc} and import 🛬</div>`;
}
// Flowers section
html += `<div class="group-title">Flowers — sets: ${fCalc.sets} | pts: ${fCalc.sets * 10}</div>`;
html += `<ul class="item-list">`;
flowersReq.shortNames.forEach(name => {
const rem = fCalc.remainder[name] ?? 0;
const ms = Math.max(0, 1 - rem);
html += `<li class="item-row">
<span class="item-name">${name}</span>
<span class="item-total">${rem}</span>
<span class="item-ms">(ms ${ms})</span>
<span class="item-loc">${flowersReq.locByShort[name] || ''}</span>
</li>`;
});
html += `</ul>`;
// Plushies low line
if (lowPlush) {
html += `<div class="low-line">🛫 Low on ${lowPlush.short} — travel to ${lowPlush.country} ${lowPlush.loc} and import 🛬</div>`;
}
// Plushies section
html += `<div class="group-title">Plushies — sets: ${pCalc.sets} | pts: ${pCalc.sets * 10}</div>`;
html += `<ul class="item-list">`;
plushReq.shortNames.forEach(name => {
const rem = pCalc.remainder[name] ?? 0;
const ms = Math.max(0, 1 - rem);
html += `<li class="item-row">
<span class="item-name">${name}</span>
<span class="item-total">${rem}</span>
<span class="item-ms">(ms ${ms})</span>
<span class="item-loc">${plushReq.locByShort[name] || ''}</span>
</li>`;
});
html += `</ul>`;
contentEl.innerHTML = html;
}
// Load data from Torn API and render
async function loadData() {
contentEl.innerHTML = '';
summaryEl.innerHTML = '';
if (!apiKey) {
statusEl.textContent = 'No API key set. Click "Set API Key".';
return;
}
statusEl.textContent = 'Fetching display via API...';
try {
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
statusEl.textContent = `API error: ${data.error.error} (code ${data.error.code})`;
contentEl.innerHTML = '';
return;
}
const itemsAgg = aggregateDisplay(data);
// If API returns display as object of item ids, some keys might be the short names already.
// aggregateDisplay gathers counts keyed by the API full item names; we use our known full names lists above.
if (Object.keys(itemsAgg).length === 0) {
statusEl.textContent = 'No display items found. Key may lack permission.';
return;
}
// Render UI
renderUI(itemsAgg);
statusEl.textContent = 'Loaded.';
} catch (err) {
statusEl.textContent = 'Fetch failed.';
contentEl.innerHTML = `<div style="color:#f88;">${err.message}</div>`;
}
}
// on start
if (!apiKey) askKey(false);
else loadData();
})();