MWI Dungeon Timer

Automatically displays the time taken between dungeon runs in Milky Way Idle chat.

// ==UserScript==
// @name         MWI Dungeon Timer
// @namespace    http://tampermonkey.net/
// @version      1.11
// @author       qu
// @description  Automatically displays the time taken between dungeon runs in Milky Way Idle chat.
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @grant        GM.registerMenuCommand
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.listValues
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(async function () {
  'use strict';

  const MSG_SEL = '[class^="ChatMessage_chatMessage"]';
  const TIME_PART_RE = '(?:\\d{1,2}\/\\d{1,2} )?(\\d{1,2}):(\\d{2}):(\\d{2})(?: ([AP]M))?';
  const FULL_TIMESTAMP_RE = new RegExp(`^\\[${TIME_PART_RE}\\]`);
  const KEY_COUNTS_RE = new RegExp(`^\\[${TIME_PART_RE}\\] Key counts: `);
  const BATTLE_ENDED_RE = new RegExp(`\\[${TIME_PART_RE}\\] Battle ended: `);
  const PARTY_FAILED_RE = new RegExp(`\\[${TIME_PART_RE}\\] Party failed on wave \\d+`);

  const TEAM_DATA_KEY = 'dungeonTimer_teamRuns';
  let teamRuns = {};
  let previousTimes = [];
  let isVerboseLoggingEnabled = false;
  let previousFastestMsg = null;

  // UI Setup
  GM.registerMenuCommand('Toggle Verbose Logging', async () => {
    isVerboseLoggingEnabled = !isVerboseLoggingEnabled;
    await GM.setValue('verboseLogging', isVerboseLoggingEnabled);
    console.log(`[DungeonTimer] Verbose logging ${isVerboseLoggingEnabled ? 'enabled' : 'disabled'}`);
  });

  // Initialize settings and data
  initDungeonTimer();

  async function initDungeonTimer() {
    isVerboseLoggingEnabled = await GM.getValue('verboseLogging', false);
    try {
      const raw = localStorage.getItem(TEAM_DATA_KEY);
      teamRuns = raw ? JSON.parse(raw) : {};
    } catch (e) {
      console.warn('[DungeonTimer] Failed to load team data:', e);
    }

    setupUIPanel();

    // Wait 1.5 seconds for the chat to populate before scanning.
    setTimeout(() => {
      scanAndAnnotate();
    }, 1500);

    const observer = new MutationObserver(mutations => {
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (!(node instanceof HTMLElement)) continue;
          const msg = node.matches?.(MSG_SEL) ? node : node.querySelector?.(MSG_SEL);
          if (!msg) continue;
          scanAndAnnotate();
        }
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // ===================== Core Logic ======================

  function extractChatEvents() {
    maybeLog('extractChatEvents');
    const nodes = [...document.querySelectorAll(MSG_SEL)];
    const events = [];

    for (const node of nodes) {
      if (node.dataset.processed === '1') continue;
      const text = node.textContent.trim();
      const timestamp = getTimestampFromMessage(node);
      if (!timestamp) continue;

      if (KEY_COUNTS_RE.test(text)) {
        const team = getTeamFromMessage(node);
        if (!team.length) continue;
        events.push({ type: 'key', timestamp, team, msg: node });
      } else if (PARTY_FAILED_RE.test(text)) {
        events.push({ type: 'fail', timestamp, msg: node });
        node.dataset.processed = '1';
      } else if (BATTLE_ENDED_RE.test(text)) {
        events.push({ type: 'cancel', timestamp, msg: node });
        node.dataset.processed = '1';
      }
    }

    return events;
  }

  function annotateChatEvents(events) {
    maybeLog('annotateChatEvents');
    previousTimes.length = 0;

    for (let i = 0; i < events.length; i++) {
      const e = events[i];
      if (e.type !== 'key') continue;

      const next = events[i + 1];
      let label = null;
      let diff = null;

      if (next?.type === 'key') {
        diff = next.timestamp - e.timestamp;
        if (diff < 0) {
          diff += 24 * 60 * 60 * 1000; // handle midnight rollover
        }
        label = formatDuration(diff);

        const teamKey = e.team.join(',');
        const entry = { timestamp: e.timestamp.toISOString(), diff };

        teamRuns[teamKey] ??= [];
        const isDuplicate = teamRuns[teamKey].some(
          r => r.timestamp === entry.timestamp && r.diff === entry.diff
        );
        if (!isDuplicate) {
          teamRuns[teamKey].push(entry);
        }

        previousTimes.push({ msg: e.msg, diff });
      } else if (next?.type === 'fail') {
        label = 'FAILED';
      } else if (next?.type === 'cancel') {
        label = 'canceled';
      }

      if (label){
        e.msg.dataset.processed = '1';
        insertDungeonTimer(label, e.msg);
      }
    }

    saveTeamRuns();
    updateStatsPanel();
  }

  function scanAndAnnotate() {
    const events = extractChatEvents();
    annotateChatEvents(events);
  }

  // ===================== Utilities ======================

  function maybeLog(logMessage) {
    if (isVerboseLoggingEnabled) {
      console.log("[DungeonTimer] " + logMessage);
    }
  }

  function getTimestampFromMessage(msg) {
    const match = msg.textContent.trim().match(FULL_TIMESTAMP_RE);
    if (!match) return null;

    let [_, hour, min, sec, period] = match;
    hour = parseInt(hour, 10);
    min = parseInt(min, 10);
    sec = parseInt(sec, 10);

    if (period === 'PM' && hour !== 12) hour += 12;
    if (period === 'AM' && hour === 12) hour = 0;

    const date = new Date();
    date.setHours(hour, min, sec, 0);
    return date;
  }

  function getTeamFromMessage(msg) {
    const text = msg.textContent.trim();
    const matches = [...text.matchAll(/\[([^\[\]-]+?)\s*-\s*\d+\]/g)];
    return matches.map(m => m[1].trim()).sort();
  }

  function insertDungeonTimer(label, msg) {
    if (msg.dataset.timerAppended === '1') return;

    const spans = msg.querySelectorAll('span');
    if (spans.length < 2) return;

    const messageSpan = spans[1];
    const timerSpan = document.createElement('span');
    timerSpan.textContent = ` [${label}]`;
    timerSpan.classList.add('dungeon-timer');

    if (label === 'FAILED') timerSpan.style.color = '#ff4c4c';
    else if (label === 'canceled') timerSpan.style.color = '#ffd700';
    else timerSpan.style.color = '#90ee90';

    timerSpan.style.fontSize = '90%';
    timerSpan.style.fontStyle = 'italic';

    messageSpan.appendChild(timerSpan);
    msg.dataset.timerAppended = '1';
  }

  function formatDuration(ms) {
    const totalSeconds = Math.floor(ms / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes}m ${seconds}s`;
  }

  function saveTeamRuns() {
    try {
      localStorage.setItem(TEAM_DATA_KEY, JSON.stringify(teamRuns));
    } catch (e) {
      console.error('[DungeonTimer] Failed to save teamRuns:', e);
    }
  }

  // ===================== UI Panel ======================

  function setupUIPanel() {
    if (document.getElementById('dungeon-timer-panel')) return;

    const panel = document.createElement('div');
    panel.id = 'dungeon-timer-panel';
    panel.style.position = 'fixed';
    panel.style.right = '6px';
    panel.style.bottom = '6px';
    panel.style.width = '260px';
    panel.style.maxHeight = '50vh';
    panel.style.overflowY = 'auto';
    panel.style.zIndex = '9999';
    panel.style.background = '#222';
    panel.style.color = '#fff';
    panel.style.fontSize = '12px';
    panel.style.border = '1px solid #888';
    panel.style.borderRadius = '6px';

    // Header for dragging and minimizing
    const header = document.createElement('div');
    header.style.cursor = 'move';
    header.style.background = '#444';
    header.style.padding = '4px 6px';
    header.style.fontWeight = 'bold';
    header.style.display = 'flex';
    header.style.justifyContent = 'space-between';
    header.style.alignItems = 'center';
    header.innerHTML = `
      <span>Dungeon Stats</span>
      <button id="dungeon-toggle-btn" style="background:#333; color:#fff; border:none; cursor:pointer;">−</button>
    `;

    // Content container
    const content = document.createElement('div');
    content.id = 'dungeon-panel-content';
    content.style.padding = '6px';

    // Clear Button
    const clearBtn = document.createElement('button');
    clearBtn.textContent = 'Clear';
    clearBtn.style.background = '#a33';
    clearBtn.style.color = '#fff';
    clearBtn.style.border = 'none';
    clearBtn.style.cursor = 'pointer';
    clearBtn.style.padding = '4px 8px';
    clearBtn.style.margin = '6px';
    clearBtn.style.borderRadius = '4px';
    clearBtn.style.justifyContent = 'center';
    clearBtn.style.display = 'block';
    clearBtn.style.margin = '0 auto';
    clearBtn.style.marginBottom = '8px';


    clearBtn.addEventListener('click', () => {
      if (confirm('Clear previous dungeon run data?')) {
        teamRuns = {};
        saveTeamRuns();
        updateStatsPanel();
      }
    });

    panel.appendChild(header);
    panel.appendChild(content);
    panel.appendChild(clearBtn);
    document.body.appendChild(panel);

    makeDraggable(panel, header);

    // Minimize/Expand toggle
    document.getElementById('dungeon-toggle-btn').addEventListener('click', () => {
      if (content.style.display === 'none') {
        content.style.display = 'block';
        clearBtn.style.display = 'block';
        document.getElementById('dungeon-toggle-btn').textContent = '−';
      } else {
        content.style.display = 'none';
        clearBtn.style.display = 'none';
        document.getElementById('dungeon-toggle-btn').textContent = '+';
      }
    });
  }

  function makeDraggable(panel, handle) {
    let isDragging = false;
    let offsetX, offsetY;

    handle.addEventListener('mousedown', e => {
      isDragging = true;
      offsetX = e.clientX - panel.getBoundingClientRect().left;
      offsetY = e.clientY - panel.getBoundingClientRect().top;
      document.body.style.userSelect = 'none';
    });

    document.addEventListener('mousemove', e => {
      if (!isDragging) return;
      panel.style.left = `${e.clientX - offsetX}px`;
      panel.style.top = `${e.clientY - offsetY}px`;
      panel.style.right = 'auto';
      panel.style.bottom = 'auto';
    });

    document.addEventListener('mouseup', () => {
      isDragging = false;
      document.body.style.userSelect = '';
    });
  }

  function updateStatsPanel() {
    const container = document.querySelector('#dungeon-panel-content');
    if (!container) return;

    container.innerHTML = '';

    for (const [teamKey, runs] of Object.entries(teamRuns)) {
      if (!runs.length) continue;

      const times = runs.map(r => r.diff);
      const avg = Math.floor(times.reduce((a, b) => a + b, 0) / times.length);
      const best = Math.min(...times);
      const worst = Math.max(...times);

      const bestTime = runs.find(r => r.diff === best)?.timestamp;
      const worstTime = runs.find(r => r.diff === worst)?.timestamp;

      const line = document.createElement('div');
      line.innerHTML = `
        <strong>${teamKey}</strong> (${runs.length} runs)<br/>
        Avg: ${formatDuration(avg)}<br/>
        Best: ${formatDuration(best)} (${formatShortDate(bestTime)})<br/>
        Worst: ${formatDuration(worst)} (${formatShortDate(worstTime)})
      `;
      container.appendChild(line);
    }
  }

  function formatShortDate(isoStr) {
    const d = new Date(isoStr);
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  }
})();