GGn Mining Stats

Adds a button to the userlog page to calculate personal mining drops statistics and copy them to clipboard (IRC colors). Quick auto-dismissing popup for copy feedback. Also counts random staff cards and ore received.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GGn Mining Stats
// @description  Adds a button to the userlog page to calculate personal mining drops statistics and copy them to clipboard (IRC colors). Quick auto-dismissing popup for copy feedback. Also counts random staff cards and ore received.
// @namespace    https://gazellegames.net/
// @version      1.1.420
// @license      MIT
// @match        https://gazellegames.net/user.php?action=userlog
// @grant        GM_setValue
// @grant        GM_getValue
// @icon         https://gazellegames.net/favicon.ico
// @supportURL   https://github.com/freshwater/userscripts
// ==/UserScript==

(function() {
  'use strict';
  const userLink = document.querySelector('h2 a.username');
  if (!userLink) return;
  const href = userLink.getAttribute('href');
  const userId = new URL(href, window.location.href).searchParams.get('id');
  if (!userId) return;

  const header = document.querySelector('h2');
  if (!header) return;

  // --- Toast helper (auto-dismissing non-blocking popup) ---
  function ensureToastContainer() {
    let container = document.getElementById('ggn-mining-toast-container');
    if (!container) {
      container = document.createElement('div');
      container.id = 'ggn-mining-toast-container';
      Object.assign(container.style, {
        position: 'fixed',
        right: '16px',
        bottom: '16px',
        zIndex: 999999,
        display: 'flex',
        flexDirection: 'column',
        gap: '8px',
        alignItems: 'flex-end',
        pointerEvents: 'none'
      });
      document.body.appendChild(container);
    }
    return container;
  }

  function showToast(message, opts = {}) {
    const { duration = 2500, type = 'info' } = opts;
    const container = ensureToastContainer();
    const toast = document.createElement('div');
    toast.className = 'ggn-mining-toast';
    toast.textContent = message;
    Object.assign(toast.style, {
      pointerEvents: 'auto',
      background: type === 'error' ? 'rgba(220,50,47,0.95)' : 'rgba(40,40,40,0.95)',
      color: 'white',
      padding: '8px 12px',
      borderRadius: '6px',
      boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
      fontSize: '13px',
      opacity: '0',
      transform: 'translateY(8px)',
      transition: 'opacity 220ms ease, transform 220ms ease',
      maxWidth: '360px',
      wordBreak: 'break-word'
    });

    container.appendChild(toast);

    requestAnimationFrame(() => {
      toast.style.opacity = '1';
      toast.style.transform = 'translateY(0)';
    });

    toast.addEventListener('click', () => {
      dismiss();
    });

    let hideTimer = setTimeout(dismiss, duration);

    function dismiss() {
      clearTimeout(hideTimer);
      toast.style.opacity = '0';
      toast.style.transform = 'translateY(8px)';
      toast.addEventListener('transitionend', () => {
        try { toast.remove(); } catch (e) {}
      }, { once: true });
    }

    return { dismiss };
  }

  // --- Buttons ---
  const btn = document.createElement('button');
  btn.textContent = 'Mining Stats';
  Object.assign(btn.style, {
    marginLeft: '8px',
    border: '1px solid white',
    cursor: 'pointer'
  });

  const copyBtn = document.createElement('button');
  copyBtn.textContent = 'Copy Stats';
  Object.assign(copyBtn.style, {
    marginLeft: '8px',
    border: '1px solid white',
    cursor: 'pointer'
  });

  // --- helper functions ---
  async function fetchData(url, apiKey) {
    const response = await fetch(url, { headers: { 'X-API-Key': apiKey } });
    if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status });
    const data = await response.json();
    if (data?.status !== 'success') throw new Error(data?.error || 'API request failed');
    return data;
  }

  // Compute stats including staff card and ore counts and produce formatted strings
  function computeStats(logData, userData) {
    const drops = logData?.response || [];

    // Flames
    const flameEntries = drops.filter(e => e.message.toLowerCase().includes('flame'));
    const flameCounts = flameEntries.reduce((acc, entry) => {
      const msg = entry.message.toLowerCase();
      ['nayru', 'din', 'farore'].forEach(flame => {
        if (msg.includes(`${flame}'s flame`)) acc[flame]++;
      });
      return acc;
    }, { nayru: 0, din: 0, farore: 0 });

    // Staff card detection (case-insensitive, look for 'staff card')
    const staffPattern = /staff card/i;
    const staffEntries = drops.filter(e => staffPattern.test(e.message));
    const staffCount = staffEntries.length;

    // Ore detection: look for standalone "ore" or "ores" (word boundary)
    const orePattern = /\bore(s)?\b/i;
    const oreEntries = drops.filter(e => orePattern.test(e.message));
    const oreCount = oreEntries.length;

    const actualLines = userData?.response?.community?.ircActualLines ?? 0;
    const totalMines = drops.length;
    const totalFlames = flameEntries.length;
    const linesPerMine = actualLines / (totalMines || 1);
    const linesPerFlame = actualLines / (totalFlames || 1);

    // Plain alert formatting (multi-line)
    const plainAlert = `Mining Stats:
Mines: ${totalMines} | Flames: ${totalFlames}
Nayru: ${flameCounts.nayru}, Din: ${flameCounts.din}, Farore: ${flameCounts.farore}
Staff cards: ${staffCount}, Ore: ${oreCount}
Lines/Mine: ${linesPerMine.toFixed(2)}
Lines/Flame: ${linesPerFlame.toFixed(2)}`;

    // Single-line IRC formatted string: bracketed numbers, labels colored only
    const SEP = ' • '; // single space either side of bullet as in your example
    const IRC_COLOR = '\x03';
    const IRC_RESET = '\x0f';

    // Label coloring: only the label word is colored; brackets and numbers stay normal
    const totalMinesPart = `Total Mines [ ${totalMines} ]`;
    const naryuPart = `${IRC_COLOR}11Nayru${IRC_RESET} [ ${flameCounts.nayru} ]`;
    const dinPart = `${IRC_COLOR}04Din${IRC_RESET} [ ${flameCounts.din} ]`;
    const farorePart = `${IRC_COLOR}03Farore${IRC_RESET} [ ${flameCounts.farore} ]`;
    const orePart = `${IRC_COLOR}08Ore${IRC_RESET} [ ${oreCount} ]`;
    const staffPart = `Staff Cards [ ${staffCount} ]`;
    const linesMinesPart = `Lines/Mine [ ${Math.round(linesPerMine)} ]`;
    const linesFlamesPart = `Lines/Flame [ ${Math.round(linesPerFlame)} ]`;

    // Join with the bullet separator
    const singleLine = [
      totalMinesPart,
      naryuPart,
      dinPart,
      farorePart,
      orePart,
      staffPart,
      linesMinesPart,
      linesFlamesPart
    ].join(SEP);

    return {
      drops,
      flameCounts,
      staffCount,
      oreCount,
      totalMines,
      totalFlames,
      linesPerMine,
      linesPerFlame,
      plainAlert,
      singleLine
    };
  }

  // --- main handlers (preserve original prompting/retry flow) ---
  btn.addEventListener('click', async () => {
    let apiKey = GM_getValue('mining_stats_api_key');
    let needsRetry = false;
    do {
      try {
        if (!apiKey) {
          apiKey = prompt('Enter your API key (requires "User" permissions):');
          if (!apiKey) return;
          GM_setValue('mining_stats_api_key', apiKey);
        }

        console.log('[Mining Stats] Fetching data...');
        const [logData, userData] = await Promise.all([
          fetchData(`https://gazellegames.net/api.php?request=userlog&limit=-1&search=as an irc reward.`, apiKey),
          fetchData(`https://gazellegames.net/api.php?request=user&id=${userId}`, apiKey)
        ]);

        const stats = computeStats(logData, userData);

        alert(stats.plainAlert);
        needsRetry = false;
      } catch (error) {
        console.error('[Mining Stats] Error:', error);
        if ([401, 403].includes(error.status)) {
          GM_setValue('mining_stats_api_key', '');
          apiKey = null;
          needsRetry = confirm(`API Error: ${error.status === 401 ? 'Invalid key' : 'No permissions'}. Retry?`);
        } else {
          alert(`Error: ${error.message}`);
          needsRetry = false;
        }
      }
    } while (needsRetry);
  });

  copyBtn.addEventListener('click', async () => {
    let apiKey = GM_getValue('mining_stats_api_key');
    let needsRetry = false;
    do {
      try {
        if (!apiKey) {
          apiKey = prompt('Enter your API key (requires "User" permissions):');
          if (!apiKey) return;
          GM_setValue('mining_stats_api_key', apiKey);
        }

        console.log('[Mining Stats] (copy) Fetching data...');
        const [logData, userData] = await Promise.all([
          fetchData(`https://gazellegames.net/api.php?request=userlog&limit=-1&search=as an irc reward.`, apiKey),
          fetchData(`https://gazellegames.net/api.php?request=user&id=${userId}`, apiKey)
        ]);

        const stats = computeStats(logData, userData);

        // Copy to clipboard (navigator.clipboard preferred)
        try {
          if (navigator.clipboard && navigator.clipboard.writeText) {
            await navigator.clipboard.writeText(stats.singleLine);
          } else {
            const ta = document.createElement('textarea');
            ta.value = stats.singleLine;
            ta.style.position = 'fixed';
            ta.style.left = '-9999px';
            document.body.appendChild(ta);
            ta.select();
            document.execCommand('copy');
            document.body.removeChild(ta);
          }
          showToast('Mining stats copied to clipboard.');
          console.log('[Mining Stats] Copied text:', stats.singleLine);
        } catch (err) {
          console.error('[Mining Stats] Clipboard error', err);
          showToast('Failed to copy to clipboard.', { type: 'error', duration: 4000 });
          console.log('[Mining Stats] Clipboard error details. Stats:\n', stats.singleLine);
        }

        needsRetry = false;
      } catch (error) {
        console.error('[Mining Stats] Error (copy):', error);
        if ([401, 403].includes(error.status)) {
          GM_setValue('mining_stats_api_key', '');
          apiKey = null;
          needsRetry = confirm(`API Error: ${error.status === 401 ? 'Invalid key' : 'No permissions'}. Retry?`);
        } else {
          alert(`Error: ${error.message}`);
          needsRetry = false;
        }
      }
    } while (needsRetry);
  });

  header.appendChild(btn);
  header.appendChild(copyBtn);
})();