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      0.2
// @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/*
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function bootstrap() {
  if (typeof window.MWITools_marketAPI_json !== 'undefined') {
  (function waitForCompat() {
    if (typeof window.MWITools_marketAPI_json === 'undefined') {
      return setTimeout(waitForCompat, 200);
    }
    initDungeonTimer();
  })();
  } else {
  initDungeonTimer();
  }
})();

function initDungeonTimer() {
  'use strict';

  const MSG_SEL = '[class^="ChatMessage_chatMessage"]';
  const KEY_COUNTS_RE = /^\[(\d{1,2}):(\d{2}):(\d{2}) ([AP]M)\] Key counts: /;
  const previousTimes = [];
  let lastKeyTime = null;
  let runStatus = 'ok'; // options: 'ok', 'canceled', 'failed'
  let lastUrl = location.href;


  function getChatText(msg) {
    const full = msg.textContent.trim();
    return full.replace(/^\[\d{1,2}:\d{2}:\d{2} [AP]M\]\s+\S+:\s*/, '');
  }

  function getTimestampFromMessage(msg) {
    const match = msg.textContent.trim().match(/^\[(\d{1,2}):(\d{2}):(\d{2}) ([AP]M)\]/);
    if (!match) return null;

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

    // Convert to 24-hour time
    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 formatTimeDiff(ms) {
    const totalSeconds = Math.floor(ms / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes}m ${seconds}s`;
  }

  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}]`;

    if (label === 'FAILED') {
      timerSpan.style.color = '#ff4c4c'; // red
    } else if (label === 'canceled') {
      timerSpan.style.color = '#ffd700'; // yellow
    } else {
      timerSpan.style.color = '#90ee90'; // light green
    }

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

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

  function isCharacterUrl(url) {
    return /^https:\/\/www\.milkywayidle\.com\/game\?characterId=\d+$/.test(url);
  }

  function isBattleEndedMessage(msg) {
    return /\[\d{1,2}:\d{2}:\d{2} [AP]M\] Battle ended: /.test(msg.textContent);
  }

  function isPartyFailedMessage(msg) {
    return /\[\d{1,2}:\d{2}:\d{2} [AP]M\] Party failed on wave \d+/.test(msg.textContent);
  }

  function processKeyCountMessage(msg) {
    const timestamp = getTimestampFromMessage(msg);
    if (!timestamp) return;

    msg.dataset.processed = '1';

    if (lastKeyTime) {
      if (runStatus === 'failed') {
        insertDungeonTimer('FAILED', msg);
      } else if (runStatus === 'canceled') {
        insertDungeonTimer('canceled', msg);
      } else {
        let diff = lastKeyTime - timestamp;
        if (timestamp > lastKeyTime) {
          diff += 24 * 60 * 60 * 1000; // handle midnight rollover
        }
        const diffStr = formatTimeDiff(diff);
        insertDungeonTimer(diffStr, msg);
      }
    }

    // Always update lastKeyTime and reset battle flag
    lastKeyTime = timestamp;
    runStatus = 'ok'; // reset for next run
  }

  function scanExistingMessages() {
    const messages = Array.from(document.querySelectorAll(MSG_SEL)).reverse();

    previousTimes.length = 0; // Reset time tracking

    for (const msg of messages) {
      if (isBattleEndedMessage(msg)) {
        runStatus = 'canceled';
        continue;
      }

      if (isPartyFailedMessage(msg)) {
        runStatus = 'failed';
        continue;
      }

      if (msg.dataset.processed === '1') continue;
      const raw = getChatText(msg);
      if (!KEY_COUNTS_RE.test(raw)) continue;
      processKeyCountMessage(msg);
    }

    console.log('[DungeonTimer] Scanned existing messages');
  }

  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
        const newUrl = location.href;
        if (newUrl !== lastUrl && isCharacterUrl(newUrl)) {
            lastUrl = newUrl;
            initDungeonTimer();
        }

      for (const node of mutation.addedNodes) {
        if (!(node instanceof HTMLElement)) continue;
        const msg = node.matches?.(MSG_SEL) ? node : node.querySelector?.(MSG_SEL);
        if (!msg || msg.dataset.processed === '1') continue;
        const raw = getChatText(msg);
        if (!KEY_COUNTS_RE.test(raw)) continue;
        processKeyCountMessage(msg);
      }
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });

  setTimeout(scanExistingMessages, 1500); // Wait for the chat to load
  console.log('[DungeonTimer] ready');
}