Torn Display Case Sets Tracker (API - fixed)

Uses Torn public API (selection=display) to show flower and plushie set counts and per-item breakdown (total, av, ms). Prompt stores public API key locally.

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

// ==UserScript==
// @name         Torn Display Case Sets Tracker (API - fixed)
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Uses Torn public API (selection=display) to show flower and plushie set counts and per-item breakdown (total, av, ms). Prompt stores public API key locally.
// @author       Nova
// @match        https://www.torn.com/displaycase.php*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
  'use strict';

  // Required sets
  const FLOWERS = [
    "Dahlia", "Orchid", "African Violet", "Cherry Blossom", "Peony", "Ceibo Flower",
    "Edelweiss", "Crocus", "Heather", "Tribulus Omanense", "Banana Orchid"
  ];

  const PLUSHIES = [
    "Sheep Plushie", "Teddy Bear Plushie", "Kitten Plushie", "Jaguar Plushie", "Wolverine Plushie",
    "Nessie Plushie", "Red Fox Plushie", "Monkey Plushie", "Chamois Plushie", "Panda Plushie",
    "Lion Plushie", "Camel Plushie", "Stingray Plushie"
  ];

  // UI
  GM_addStyle(`
    #setTrackerPanel {
      position: fixed;
      top: 100px;
      left: 20px;
      width: 380px;
      background: #fff;
      color: #000;
      font-family: monospace;
      font-size: 12px;
      border: 1px solid #444;
      border-radius: 6px;
      padding: 8px;
      z-index: 2147483647;
      box-shadow: 0 0 10px rgba(0,0,0,0.35);
      max-height: 70vh;
      overflow-y: auto;
      line-height: 1.25;
    }
    #setTrackerPanel h4 { margin: 0 0 6px 0; font-size:13px; }
    #setTrackerPanel .controls { margin-bottom:6px; }
    #setTrackerPanel button { margin-right:6px; font-size:12px; padding:2px 6px; }
    #setTrackerPanel ul { margin: 4px 0 8px 14px; padding:0; }
    #setTrackerPanel li { margin: 2px 0; list-style: none; }
    #setTrackerPanel .item-name { display:inline-block; width:180px; }
    #setTrackerPanel .item-stats { display:inline-block; width:170px; text-align:right; }
  `);

  const panel = document.createElement('div');
  panel.id = 'setTrackerPanel';
  panel.innerHTML = `
    <h4>Display Case Sets (API)</h4>
    <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_content" style="margin-top:8px;"></div>
  `;
  document.body.appendChild(panel);

  const statusEl = panel.querySelector('#tc_status');
  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 = '';
  });

  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();
  }

  // robust parser for API display data
  function aggregateDisplay(data) {
    const items = {};
    // data.display may be array or object. also older tools use 'displaycase' or 'display'
    const displayRaw = data.display || data.displaycase || data.displayCase || null;
    if (!displayRaw) return items;

    // If object (map) convert to values
    const entries = Array.isArray(displayRaw) ? displayRaw : Object.values(displayRaw);

    for (const e of entries) {
      if (!e) continue;
      // determine name field
      const name = e.name || e.item_name || e.title || e.item || e.name_en || null;
      // determine quantity
      let qty = 0;
      if (typeof e.quantity !== 'undefined') qty = Number(e.quantity) || 0;
      else if (typeof e.qty !== 'undefined') qty = Number(e.qty) || 0;
      else if (typeof e.amount !== 'undefined') qty = Number(e.amount) || 0;
      else if (typeof e.q !== 'undefined') qty = Number(e.q) || 0;
      else qty = 1; // many display entries are singletons

      if (!name) continue;
      items[name] = (items[name] || 0) + qty;
    }
    return items;
  }

  function calcSets(required, items) {
    // counts for each required item
    const counts = required.map(n => items[n] || 0);
    const complete = counts.length ? Math.min(...counts) : 0;

    const remaining = {};
    const missing = {};
    required.forEach((n) => {
      const total = items[n] || 0;
      const av = total - complete; // available after using complete full sets
      remaining[n] = av;
      missing[n] = av >= 1 ? 0 : (1 - av); // how many needed for next set
    });

    return { complete, remaining, missing };
  }

  function render(items) {
    const flowers = calcSets(FLOWERS, items);
    const plushies = calcSets(PLUSHIES, items);

    let html = '';
    html += `<div><strong>Flowers sets:</strong> ${flowers.complete}</div>`;
    html += `<div><strong>Plushie sets:</strong> ${plushies.complete}</div>`;
    html += `<hr/>`;

    html += `<div><strong>Flowers breakdown</strong></div><ul>`;
    FLOWERS.forEach(name => {
      const total = items[name] || 0;
      const av = flowers.remaining[name];
      const ms = flowers.missing[name];
      html += `<li><span class="item-name">${name}</span><span class="item-stats">${total} &nbsp; (av ${av}, ms ${ms})</span></li>`;
    });
    html += `</ul>`;

    html += `<div><strong>Plushies breakdown</strong></div><ul>`;
    PLUSHIES.forEach(name => {
      const total = items[name] || 0;
      const av = plushies.remaining[name];
      const ms = plushies.missing[name];
      html += `<li><span class="item-name">${name}</span><span class="item-stats">${total} &nbsp; (av ${av}, ms ${ms})</span></li>`;
    });
    html += `</ul>`;

    contentEl.innerHTML = html;
  }

  async function loadData() {
    contentEl.innerHTML = '';
    if (!apiKey) {
      statusEl.textContent = 'No API key set. Click "Set API Key".';
      return;
    }
    statusEl.textContent = 'Fetching display via API...';
    try {
      // correct selection name: display
      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;
      }

      // aggregate items
      const items = aggregateDisplay(data);

      // if nothing found, fallback: try 'displaycase' selection (some keys/version)
      if (Object.keys(items).length === 0) {
        // try alternative selection
        // note: some older examples use 'display'. we've already used it, but try 'displaycase' as fallback
        const altUrl = `https://api.torn.com/user/?selections=displaycase&key=${encodeURIComponent(apiKey)}`;
        const altRes = await fetch(altUrl);
        const altData = await altRes.json();
        if (!altData.error) {
          const altItems = aggregateDisplay(altData);
          if (Object.keys(altItems).length) {
            render(altItems);
            statusEl.textContent = 'Loaded (fallback displaycase).';
            return;
          }
        }
      }

      if (Object.keys(items).length === 0) {
        statusEl.textContent = 'No display items found in API response.';
        contentEl.innerHTML = 'If you can see your display case in the site but API returns nothing, your key might lack permissions. Use a public/minimal key with display permission.';
        return;
      }

      render(items);
      statusEl.textContent = 'Loaded from API.';
    } catch (err) {
      statusEl.textContent = 'Fetch failed. Check network / key.';
      contentEl.innerHTML = `<div style="color:#900;">${err.message}</div>`;
    }
  }

  // start
  if (!apiKey) askKey(false);
  else loadData();

})();