🌺 🐫 Points Exporter

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.

目前為 2025-10-05 提交的版本,檢視 最新版本

// ==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();

})();