🌺 🐫 Points Exporter

Runs on Travel page. Reads Display + Inventory. Short names | remaining(after sets) | (need X) | CODE + flag. Auto-calculates sets and points. Bold red low-on lines with travel hint. Prompts API key on first run. Polls every 45s.

当前为 2025-10-06 提交的版本,查看 最新版本

// ==UserScript==
// @name         🌺 🐫 Points Exporter
// @namespace    http://tampermonkey.net/
// @version      3.2
// @description  Runs on Travel page. Reads Display + Inventory. Short names | remaining(after sets) | (need X) | CODE + flag. Auto-calculates sets and points. Bold red low-on lines with travel hint. Prompts API key on first run. Polls every 45s.
// @author       Nova
// @match        https://www.torn.com/page.php?sid=travel*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
  'use strict';

  if (!/page\.php\?sid=travel/.test(location.href)) return;

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

  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; }
    .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-need { flex:0 0 46px; 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; }
  `);

  const panel = document.createElement('div');
  panel.id = 'setTrackerPanel';
  panel.innerHTML = `
    <div id="setTrackerHeader">▶ 🌺 🐫 Points Exporter</div>
    <div id="setTrackerContent">
      <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');

  let apiKey = GM_getValue('tornAPIKey', null);
  const POLL_INTERVAL_MS = 45000;
  let pollHandle = null;

  async function askKey(force) {
    if (!apiKey || force) {
      const k = prompt('Enter your Torn API key (needs inventory permission):', apiKey || '');
      if (k) {
        apiKey = k.trim();
        GM_setValue('tornAPIKey', apiKey);
      }
    }
    if (apiKey) {
      startPolling();
      loadData();
    }
  }

  function startPolling() {
    if (pollHandle) return;
    pollHandle = setInterval(loadData, POLL_INTERVAL_MS);
  }

  function aggregate(data) {
    const items = {};
    const all = [];
    if (data.display) all.push(data.display);
    if (data.inventory) all.push(data.inventory);
    for (const src of all) {
      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;
      }
    }
    return items;
  }

  function buildMap(obj) {
    const full = Object.keys(obj);
    const short = full.map(k => obj[k].short);
    const loc = {}, country = {};
    full.forEach(fn => { loc[obj[fn].short] = obj[fn].loc; country[obj[fn].short] = obj[fn].country; });
    return { full, short, loc, country };
  }

  const F = buildMap(FLOWERS);
  const P = buildMap(PLUSHIES);

  function countReq(items, req, map) {
    const c = {}; req.short.forEach(s => c[s] = 0);
    req.full.forEach(fn => {
      const s = map[fn].short;
      const q = items[fn] || 0;
      c[s] = (c[s] || 0) + q;
    });
    return c;
  }

  function calc(counts, short) {
    const arr = short.map(n => counts[n] || 0);
    const sets = arr.length ? Math.min(...arr) : 0;
    const rem = {}; short.forEach(n => rem[n] = Math.max(0, (counts[n] || 0) - sets));
    return { sets, rem };
  }

  function findLowest(rem, loc, country) {
    const keys = Object.keys(rem);
    if (!keys.length) return null;
    let min = Infinity; keys.forEach(k => { if (rem[k] < min) min = rem[k]; });
    const allEqual = keys.every(k => rem[k] === min);
    if (allEqual) return null;
    const key = keys.find(k => rem[k] === min);
    return { short: key, rem: min, loc: loc[key] || '', country: country[key] || '' };
  }

  function render(items) {
    const fC = countReq(items, F, FLOWERS);
    const pC = countReq(items, P, PLUSHIES);
    const f = calc(fC, F.short);
    const p = calc(pC, P.short);

    const totalSets = f.sets + p.sets;
    const totalPts = totalSets * 10;
    summaryEl.innerHTML = `<div class="summary-line">Total sets: ${totalSets} | Points: ${totalPts}</div>`;

    const lowF = findLowest(f.rem, F.loc, F.country);
    const lowP = findLowest(p.rem, P.loc, P.country);

    let html = '';
    if (lowF) html += `<div class="low-line">🛫 Low on ${lowF.short} — travel to ${lowF.country} ${lowF.loc} and import 🛬</div>`;
    html += `<div class="group-title">Flowers — sets: ${f.sets} | pts: ${f.sets * 10}</div><ul class="item-list">`;
    F.short.forEach(n => {
      const rem = f.rem[n] ?? 0;
      const need = (f.sets + 1) - (rem + f.sets);
      const showNeed = need > 0 ? need : 0;
      html += `<li class="item-row"><span class="item-name">${n}</span><span class="item-total">${rem}</span><span class="item-need">(need ${showNeed})</span><span class="item-loc">${F.loc[n] || ''}</span></li>`;
    });
    html += `</ul>`;

    if (lowP) html += `<div class="low-line">🛫 Low on ${lowP.short} — travel to ${lowP.country} ${lowP.loc} and import 🛬</div>`;
    html += `<div class="group-title">Plushies — sets: ${p.sets} | pts: ${p.sets * 10}</div><ul class="item-list">`;
    P.short.forEach(n => {
      const rem = p.rem[n] ?? 0;
      const need = (p.sets + 1) - (rem + p.sets);
      const showNeed = need > 0 ? need : 0;
      html += `<li class="item-row"><span class="item-name">${n}</span><span class="item-total">${rem}</span><span class="item-need">(need ${showNeed})</span><span class="item-loc">${P.loc[n] || ''}</span></li>`;
    });
    html += `</ul>`;

    contentEl.innerHTML = html;
  }

  async function loadData() {
    contentEl.innerHTML = '';
    summaryEl.innerHTML = '';
    if (!apiKey) { statusEl.textContent = 'No API key set.'; askKey(false); return; }
    statusEl.textContent = 'Fetching display + inventory via API...';
    try {
      const res = await fetch(`https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(apiKey)}`);
      const data = await res.json();
      if (data.error) { statusEl.textContent = `API error: ${data.error.error} (code ${data.error.code})`; return; }
      const items = aggregate(data);
      render(items);
      statusEl.textContent = 'Loaded.';
    } catch (e) { statusEl.textContent = 'Fetch failed.'; }
  }

  if (apiKey) { startPolling(); loadData(); } else { setTimeout(() => askKey(false), 300); }
  window.addEventListener('beforeunload', () => clearInterval(pollHandle));
})();