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