DeepCo Statistics

Track RC rate and chart it.

// ==UserScript==
// @name         DeepCo Statistics
// @namespace    https://deepco.app/
// @version      2025-07-22v3
// @description  Track RC rate and chart it.
// @author       Corns
// @match        https://deepco.app/dig
// @icon         https://www.google.com/s2/favicons?sz=64&domain=deepco.app
// @license      MIT
// @grant        GM.setValue
// @grant        GM.getValue
// @require      https://code.highcharts.com/highcharts.js
// @require      https://code.highcharts.com/modules/boost.js
// @require      https://code.highcharts.com/modules/mouse-wheel-zoom.js
// ==/UserScript==

(async function() {
  'use strict';

  const SCHEMA = [['Timestamp', 'TileCount', 'RC', 'Level', 'DC', 'Players', 'DCIncome', 'Processing Rating']];

  // Load existing logs or initialize with header row
  let db = await GM.getValue('nudgeLogs', SCHEMA);
  fixTimestamps(db);
  let myChart = null;
  let recursionTime = null;

  new MutationObserver((mutation, observer) => {
    const deptScaling = document.querySelector('.department-scaling');
    if (deptScaling) {
      observer.disconnect();
      console.log("[DeepCo Stats] Started");
      applyFontPatch();
      waitForTargetAndObserve()
    }
  }).observe(document.body, { childList: true, subtree: true });

  function waitForTargetAndObserve() {
    const frame = document.getElementById('flash-messages');
    createPanel();

    const observer = new MutationObserver((mutationsList, observer) => {
      for (const mutation of mutationsList) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;
          const text = node.textContent.trim();
          if (text.includes('[DC]')) {
            logStats(text);
          }
        }
      }
    }).observe(frame, { childList: true, subtree: false });
  }

  function createPanel() {
    const grid = document.getElementById('grid-panel');
    const parentElement = document.createElement("div");
    parentElement.className = 'grid-wrapper';
    // add buttons
    const btnContainer = document.createElement("div");
    btnContainer.style.display = 'flex';
    btnContainer.style.justifyContent = 'center';
    btnContainer.style.gap = '10px'; // optional spacing between buttons
    // Export button
    const exportBtn = document.createElement("button");
    exportBtn.textContent = "Export Player Stats";
    exportBtn.addEventListener("click", exportStats);
    btnContainer.appendChild(exportBtn);

    // Reset button
    const resetBtn = document.createElement("button");
    resetBtn.textContent = "Reset Stats";
    resetBtn.addEventListener("click", resetStats);
    btnContainer.appendChild(resetBtn);

    // 2. Create container for buttons
    const chartContainer = document.createElement('div');
    chartContainer.id = 'hc-container';
    // chartContainer.style.height = '300px';
    // chartContainer.style.backgroundColor = '#fff';
    // chartContainer.style.border = '1px solid #ccc';
    // chartContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.3)';

    parentElement.appendChild(chartContainer);
    parentElement.appendChild(btnContainer);
    grid.appendChild(parentElement);

    const playerSpan = document.querySelector('.small-link span[style*="color"]');
    const playerColor = playerSpan.style.color;

    // 3. Render Highcharts chart
    myChart = Highcharts.chart('hc-container', {
      chart: {
        zooming: {
          type: 'x',
          mouseWheel: {
            enabled: true,
            type: 'x'
          }
        }
      },
      title: { text: 'RC Yield' },
      subtitle: {
        text: document.ontouchstart === undefined ?
        'Click and drag in the plot area to zoom in' :
        'Pinch the chart to zoom in'
      },
      xAxis: {
        type: 'datetime'
      },
      yAxis: {
        title: { text: 'RC/hr' }
      },
      series: [{
        name: 'RC/hr',
        data: calcData(),
        color: playerColor
      }],
      tooltip: {
        xDateFormat: '%Y-%m-%d %H:%M:%S.%L',
        pointFormat: 'RC/hr: <b>{point.y:.2f}</b><br/>'
      }
    });
  }

  function calcData() {
    const seriesData = [];

    // start at 1 to skip header
    for (let i = 1; i < db.length; i++) {
      // first row of data or RC decrease -> set recursion time to now
      if (i === 0 || db[i][2] < db[i-1][2]) {
        recursionTime = db[i][0];
      }
      const timeElapsed = (db[i][0] - recursionTime) / (1000 * 60 * 60);
      const rcPerMin = db[i][2] === 0 ? 0 : db[i][2] / timeElapsed;
      seriesData.push([db[i][0], rcPerMin]);
    }
    return seriesData;
  }

  async function logStats(flashMessage) {
    const timestamp = Date.now();
    const tileCount = getTileCount();
    const rc = getRCCount();
    const level = getLevel();
    const dc = getDCCount();
    const players = countPlayersInLevel();
    const dcIncome = getDCIncome(flashMessage);
    const rating = getProcessingRating();

    // push to database
    db.push([timestamp, tileCount, rc, level, dc, players, dcIncome, rating]);
    await GM.setValue('nudgeLogs', db);

    const timeElapsed = (timestamp - recursionTime) / (1000 * 60 * 60);
    const rcPerMin = rc === 0 ? 0 : rc / timeElapsed;

    // update chart
    myChart.series[0].addPoint([timestamp, rcPerMin], true, false);
    scrollChartToEnd(myChart);
  }

  async function exportStats() {
    const logs = await GM.getValue('nudgeLogs', []);
    if (logs.length === 0) {
      console.log('[DeepCo Stats] No logs to save.');
      return;
    }

    // Wrap values with commas in quotes
    const csvContent = logs.map(row =>
                                row.map(value =>
                                        /,/.test(value) ? `"${value}"` : value
                                       ).join(',')
                               ).join('\n');

    const blob = new Blob([csvContent], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);

    const link = document.createElement('a');
    link.href = url;
    link.download = `dc_player_stats_${new Date().toISOString().replace(/[:.]/g, "_")}.csv`;
    link.click();

    URL.revokeObjectURL(url);

    console.log('[CSV Export] Downloaded CSV with', logs.length, 'rows.');
  }

  async function resetStats() {
    if (confirm('Are you sure you want to clear player stats?')) {
      db = SCHEMA;
      await GM.setValue('nudgeLogs', db);
      alert('Tile logs have been cleared.');
    }
  }

  function getTimestampForSheets() {
    const d = new Date();
    const pad = (n, z = 2) => String(n).padStart(z, '0');

    const year = d.getFullYear();
    const month = pad(d.getMonth() + 1);
    const day = pad(d.getDate());
    const hours = pad(d.getHours());
    const minutes = pad(d.getMinutes());
    const seconds = pad(d.getSeconds());
    const milliseconds = pad(d.getMilliseconds(), 3);

    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
  }

  // convert to ISO 8601 format i.e. milliseconds since epoch
  function fixTimestamps(db) {
    for (let i = 1; i < db.length; i++) {
      const ts = db[i][0];

      if (typeof ts === 'string') {
        db[i][0] = new Date(ts.replace(' ', 'T')).getTime();
      }
    }
  }

  function getTileCount() {
    const frame = document.getElementById('tiles-defeated-badge');
    if (!frame) {
      console.log('[DeepCo Stats] turbo-frame element not found.');
      return;
    }

    // Try strong with class nudge-animation first
    let target = frame.querySelector('strong.nudge-animation');

    // If not found, try strong inside span.tile-progression with style containing 'inline-block'
    if (!target) {
      target = frame.querySelector('span.tile-progression strong[style*="inline-block"]');
    }

    if (!target) {
      console.log('[DeepCo Stats] Target element not found inside turbo-frame.');
      return;
    }

    let value = target.textContent.trim();
    value = parseInt(value.replace(/[^\d]/g, ""), 10);
    return value;
  }

  function getRCCount() {
    // Find RC value
    const recursionSpan = document.getElementById('recursion-header');
    let rc = 0;
    if (recursionSpan) {
      const a = recursionSpan.querySelector('a');
      if (a) {
        // Extract RC value using regex, e.g. [+15 RC]
        const rcMatch = a.textContent.match(/\[\+([\d.]+)\s*RC\]/);
        if (rcMatch) {
          rc = parseFloat(rcMatch[1]);
        }
      }
    }
    return rc;
  }

  function getDCCount() {
    // Find the UPGRADES link with badge
    const upgradesLink = Array.from(document.querySelectorAll('a')).find(a =>
                                                                         a.textContent.includes('UPGRADES'));
    // Parse current DC value from its text
    const match = upgradesLink.textContent.match(/\[DC\]\s*([\d,.]+)/);
    const currentDC = match ? parseFloat(match[1].replace(/,/g, '')) : 0;
    return currentDC;
  }

  // includes the current player
  function countPlayersInLevel() {
    const deptScaling = document.querySelector('.department-scaling');
    const deptOperators = deptScaling ? deptScaling.querySelectorAll('a').length : 0;
    return deptOperators;
  }

  function getDCIncome(flashMessage) {
    // parse flashMessage which has DC income amount
    const match = flashMessage.match(/\b\d+(\.\d+)?\s*\[DC\]/i);
    const amount = parseFloat(match[1].replace(/,/g, ''));
    return amount;
  }

  function getProcessingRating() {
    const rating = Array.from(document.querySelectorAll('.stat-item'))
    .map(item => {
      const label = item.querySelector('.stat-label');
      const value = item.querySelector('.stat-value');
      return label && label.textContent.trim() === 'Processing Rating:' && value
        ? parseFloat(value.textContent.trim())
      : null;
    })
    .filter(Boolean)[0] || null;

    return rating;
  }

  function getLevel() {
    // Find the department-stats element
    const deptStats = document.querySelector('p.department-stats');

    let dcValue = 0; // default if not found

    if (deptStats) {
      const text = deptStats.textContent.trim();

      // Match DC followed by optional + and digits, e.g., DC4A or DC+4
      const match = text.match(/DC\+?(\d+)/i);
      if (match) {
        dcValue = parseInt(match[1], 10);
      }
    }
    return dcValue;
  }

  function scrollChartToEnd(chart, bufferRatio = 0.01) {
    const xAxis = chart.xAxis[0];
    const extremes = xAxis.getExtremes();

    const viewSize = extremes.max - extremes.min;
    const dataMax = extremes.dataMax;

    const isAtEnd = Math.abs(extremes.max - dataMax) < viewSize * 0.05; // ~5% tolerance

    if (isAtEnd) {
      const buffer = viewSize * bufferRatio;
      const newMax = dataMax + buffer;
      const newMin = newMax - viewSize;

      xAxis.setExtremes(newMin, newMax);
    }
  }


  // stylise highcharts font
  function applyFontPatch() {
    if (typeof Highcharts === 'undefined' || !document.body) {
      requestAnimationFrame(applyFontPatch);
      return;
    }

    const bodyStyles = getComputedStyle(document.body);
    const bodyFont = bodyStyles.fontFamily;
    const fontColor = bodyStyles.color;
    const backgroundColor = bodyStyles.backgroundColor;


    Highcharts.setOptions({
      chart: {
        backgroundColor: 'rgb(17, 17, 17)',
        style: {
          color: fontColor,
          fontFamily: bodyFont
        }
      },
      title: {
        style: {
          color: fontColor
        }
      },
      subtitle: {
        style: {
          color: fontColor
        }
      },
      xAxis: {
        labels: {
          style: {
            color: fontColor
          }
        },
        title: {
          style: {
            color: fontColor
          }
        }
      },
      yAxis: {
        labels: {
          style: {
            color: fontColor
          }
        },
        title: {
          style: {
            color: fontColor
          }
        }
      },
      legend: {
        itemStyle: {
          color: fontColor
        }
      },
      tooltip: {
        backgroundColor: backgroundColor,
        style: {
          color: fontColor
        }
      },
    });
  }
})();