MWI Dungeon Timer

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

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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