Dungeon Floor Timer

Track dungeon floor group times with speedrun-style comparison & extra boss spawn counter for Milky Way Idle

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(I already have a user script manager, let me install it!)

Advertisement:

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.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Dungeon Floor Timer
// @name:zh-CN   地牢计时器
// @name:zh-TW   地牢計時器
// @namespace    http://tampermonkey.net/
// @version      1.12
// @description  Track dungeon floor group times with speedrun-style comparison & extra boss spawn counter for Milky Way Idle
// @description:zh-CN  银河奶牛放置 - 地牢每5层分组计时,支持多轮均时对比(Speedrun风格)+ 额外Boss刷新统计
// @description:zh-TW  銀河奶牛放置 - 地牢每5層分組計時,支持多輪均時對比(Speedrun風格)+ 額外Boss刷新統計
// @license      MIT
// @author       SeaL773
// @match        https://www.milkywayidle.com/*
// @match        https://www.milkywayidlecn.com/*
// @icon         https://www.milkywayidle.com/favicon.svg
// @grant        unsafeWindow
// @run-at       document-start
// @supportURL   https://github.com/SeaL773/mwi-dungeon-timer/issues
// @homepageURL  https://github.com/SeaL773/mwi-dungeon-timer
// @compatible   chrome
// @compatible   firefox
// @compatible   edge
// ==/UserScript==

(function () {
    "use strict";

    // ── i18n ──
    const LANG_CACHE_KEY = "dft_lang";

    function detectZH() {
        // 1. CN domain is always Chinese
        if (location.hostname.includes("milkywayidlecn")) return true;
        // 2. Check game's language dropdown (most reliable when settings page is open)
        const allInputs = document.querySelectorAll('input.MuiSelect-nativeInput');
        for (const inp of allInputs) {
            if (inp.value === "zh") { localStorage.setItem(LANG_CACHE_KEY, "zh"); return true; }
            if (inp.value === "en") { localStorage.setItem(LANG_CACHE_KEY, "en"); return false; }
        }
        // 3. Check page title (updates in real-time when language changes)
        const title = document.title || "";
        if (title.includes("银河") || title.includes("奶牛") || title.includes("战斗") || title.includes("技能")) {
            localStorage.setItem(LANG_CACHE_KEY, "zh");
            return true;
        }
        if (title.includes("Milky Way") || title.includes("Combat") || title.includes("Skills")) {
            localStorage.setItem(LANG_CACHE_KEY, "en");
            return false;
        }
        // 4. Fallback: cached detection
        const cached = localStorage.getItem(LANG_CACHE_KEY);
        if (cached === "zh") return true;
        if (cached === "en") return false;
        return false;
    }

    let isZH = false;
    // Re-detect language periodically (handles language switch without page reload)
    function updateLang() {
        const newZH = detectZH();
        if (newZH !== isZH) {
            isZH = newZH;
            L = isZH ? zhStrings : enStrings;
            // Rebuild panel header with new language
            if (panelEl) {
                panelEl.querySelector("#dft_hdr span").textContent = L.title;
                panelEl.querySelector("#dft_rst").textContent = L.reset;
                panelEl.querySelector("#dft_tog").textContent = panelExpanded ? L.collapse : L.expand;
            }
            render();
        }
    }

    const zhStrings = {
        title: "⏱ 地牢计时器",
        reset: "重置",
        confirmReset: "确定要重置所有数据吗?",
        collapse: "收起",
        expand: "展开",
        wave: "波次",
        elapsed: "已用",
        waitAlign: "等待下一组开始计时...",
        waitNext: "等待下一轮...",
        partial: "(不完整轮)",
        colFloor: "层数",
        colTime: "用时",
        colAvg: "均时",
        colDiff: "对比",
        colExtra: "额外",
        colExtraAvg: "平均",
        total: "总计",
        history: "历史",
        runs: "轮",
        avgTime: "平均",
    };
    const enStrings = {
        title: "⏱ Dungeon Timer",
        reset: "Reset",
        confirmReset: "Reset all data?",
        collapse: "Hide",
        expand: "Show",
        wave: "Wave",
        elapsed: "Elapsed",
        waitAlign: "Waiting for next group...",
        waitNext: "Waiting for next run...",
        partial: "(partial)",
        colFloor: "Floors",
        colTime: "Time",
        colAvg: "Avg",
        colDiff: "Diff",
        colExtra: "Extra",
        colExtraAvg: "Avg",
        total: "Total",
        history: "History",
        runs: "runs",
        avgTime: "Avg",
    };
    let L = enStrings;  // default, updated by updateLang()

    // ── Dungeon config ──
    const DUNGEONS = {
        "/actions/combat/chimerical_den":     { zhName: "奇幻洞穴", enName: "Chimerical Den", maxWaves: 50 },
        "/actions/combat/sinister_circus":    { zhName: "阴森马戏团", enName: "Sinister Circus", maxWaves: 60 },
        "/actions/combat/enchanted_fortress": { zhName: "秘法要塞", enName: "Enchanted Fortress", maxWaves: 65 },
        "/actions/combat/pirate_cove":        { zhName: "海盗基地", enName: "Pirate Cove", maxWaves: 65 },
    };

    function dungeonName(hrid) {
        const d = DUNGEONS[hrid];
        if (!d) return hrid;
        return isZH ? d.zhName : d.enName;
    }

    // Boss definitions per dungeon
    // trackable = bosses that can spawn on non-fixed waves (we count these)
    // finalOnly = bosses that ONLY appear on the final wave(s) (excluded)
    const DUNGEON_BOSSES = {
        // Only bosses that appear in randomSpawnInfoMap (can spawn on non-fixed waves)
        "/actions/combat/chimerical_den": {
            trackable: {
                // Butterjerry in random pool but too weak — excluded
                "/monsters/jackalope":    { zh: "鹿角兔",   en: "Jackalope" },       // random 30+
            },
            finalOnly: ["/monsters/griffin"],
        },
        "/actions/combat/sinister_circus": {
            trackable: {
                // Rabid Rabbit in random pool but too weak — excluded
                "/monsters/zombie_bear":  { zh: "僵尸熊",  en: "Zombie Bear" },       // random 40+
            },
            finalOnly: ["/monsters/deranged_jester"],
        },
        "/actions/combat/enchanted_fortress": {
            trackable: {
                // Enchanted Pawn in random pool but too weak — excluded
                // Knight, Bishop, Rook never in random pool — fixed waves only
            },
            finalOnly: ["/monsters/enchanted_queen", "/monsters/enchanted_king"],
        },
        "/actions/combat/pirate_cove": {
            trackable: {
                // Squawker in random pool but too weak — excluded
                "/monsters/anchor_shark":    { zh: "持锚鲨",    en: "Anchor Shark" },  // random 40+
                "/monsters/brine_marksman":  { zh: "海盐射手",   en: "Brine Marksman" },// random 40+
                "/monsters/tidal_conjuror":  { zh: "潮汐召唤师", en: "Tidal Conjuror" },// random 40+
            },
            finalOnly: ["/monsters/the_kraken"],
            fixedWaveBoss: { 60: "/monsters/captain_fishhook" },
        },
    };

    const GROUP = 5;

    // ── State ──
    let currentDungeon = null;
    let currentWave = -1;
    let waveStartTime = null;
    let dungeonStartTime = null;
    let isDungeonActive = false;
    let waitingForCleanGroup = false;
    let isPartialRun = false;
    let currentRunGroups = {};
    let currentRunBossCounts = {};
    let currentRunBossPerGroup = {};
    let totalBossCounts = {};
    let totalBossPerGroup = {};
    let totalRuns = 0;
    let runHistory = [];
    let panelExpanded = true;
    let reachedFinalWave = false;
    let inLabyrinth = false;

    // ── Persistence (per-dungeon) ──
    const STORAGE_KEY = "dft_history_v2";

    function storageKeyFor(dungeonHrid) {
        return dungeonHrid || "_unknown";
    }

    function saveHistory() {
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            const allData = raw ? JSON.parse(raw) : {};
            const key = storageKeyFor(currentDungeon);
            allData[key] = {
                runHistory,
                totalBossCounts,
                totalBossPerGroup,
                totalRuns,
            };
            localStorage.setItem(STORAGE_KEY, JSON.stringify(allData));
        } catch (_) {}
    }

    function loadHistory() {
        loadHistoryFor(currentDungeon);
    }

    function loadHistoryFor(dungeonHrid) {
        runHistory = [];
        totalBossCounts = {};
        totalBossPerGroup = {};
        totalRuns = 0;
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            if (!raw) return;
            const allData = JSON.parse(raw);
            const key = storageKeyFor(dungeonHrid);
            const data = allData[key];
            if (!data) return;
            if (data.runHistory) runHistory = data.runHistory;
            if (data.totalBossCounts) totalBossCounts = data.totalBossCounts;
            if (data.totalBossPerGroup) totalBossPerGroup = data.totalBossPerGroup;
            if (data.totalRuns) totalRuns = data.totalRuns;
        } catch (_) {}
    }

    // ── Helpers ──
    function fmt(ms) {
        if (!ms || ms <= 0) return "0s";
        const s = Math.floor(ms / 1000);
        const m = Math.floor(s / 60);
        const sec = s % 60;
        return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
    }

    function fmtDiff(ms) {
        const abs = Math.abs(ms);
        const s = Math.floor(abs / 1000);
        const m = Math.floor(s / 60);
        const sec = s % 60;
        return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
    }

    function groupLabel(wave, maxWaves) {
        if (wave === maxWaves) return `${maxWaves}`;
        const start = Math.floor((wave - 1) / GROUP) * GROUP + 1;
        let end = start + GROUP - 1;
        if (end >= maxWaves) end = maxWaves - 1;
        return `${start}-${end}`;
    }

    function allLabels(maxWaves) {
        const labels = [];
        for (let i = 1; i <= maxWaves; i += GROUP) {
            const end = Math.min(i + GROUP - 1, maxWaves);
            if (end === maxWaves && i < maxWaves) {
                labels.push(`${i}-${maxWaves - 1}`);
                labels.push(`${maxWaves}`);
            } else if (i === maxWaves) {
                labels.push(`${maxWaves}`);
            } else {
                labels.push(`${i}-${end}`);
            }
        }
        return labels;
    }

    function isGroupStart(wave) {
        return (wave - 1) % GROUP === 0;
    }

    function getHistoryAvg() {
        const avg = {};
        if (runHistory.length === 0) return avg;
        for (const run of runHistory) {
            for (const [label, data] of Object.entries(run.groups)) {
                if (!avg[label]) avg[label] = { total: 0, runs: 0 };
                avg[label].total += data.total;
                avg[label].runs++;
            }
        }
        for (const label of Object.keys(avg)) {
            avg[label].avg = avg[label].total / avg[label].runs;
        }
        return avg;
    }

    // ── Boss detection ──
    function detectBosses(msg) {
        if (!currentDungeon || !msg.monsters) return;
        const bossConfig = DUNGEON_BOSSES[currentDungeon];
        if (!bossConfig) return;

        const wave = msg.wave;
        const maxWaves = DUNGEONS[currentDungeon]?.maxWaves || 65;

        if (wave === maxWaves) return;
        if (wave % GROUP === 0) return;

        const label = groupLabel(wave, maxWaves);
        let foundBoss = false;

        for (const monster of msg.monsters) {
            const hrid = monster.hrid;
            if (!hrid) continue;
            if (bossConfig.trackable[hrid]) {
                if (!currentRunBossCounts[hrid]) currentRunBossCounts[hrid] = 0;
                currentRunBossCounts[hrid]++;
                foundBoss = true;
            }
        }

        if (foundBoss) {
            if (!currentRunBossPerGroup[label]) currentRunBossPerGroup[label] = 0;
            currentRunBossPerGroup[label]++;
        }
    }

    // ── Panel ──
    let panelEl = null;

    function ensurePanel() {
        if (panelEl) return;
        if (!document.body) return;

        panelEl = document.createElement("div");
        panelEl.style.cssText = `
            position:fixed; top:50px; right:50px; z-index:9999;
            font-size:0.8rem; padding:8px 10px; border-radius:16px;
            box-shadow:rgba(0,0,0,0.3) 0 4px 12px;
            overflow:auto; max-height:80vh;
            backdrop-filter:blur(8px);
            background:rgba(0,0,0,0.5);
            border:1px solid rgba(255,255,255,0.2);
            color:white; font-family:monospace;
            display:none;
        `;
        panelEl.innerHTML = `
            <div id="dft_hdr" style="display:flex;justify-content:space-between;align-items:center;cursor:move;margin-bottom:4px;">
                <span style="font-weight:bold;font-size:0.95rem;color:#4fc3f7;">${L.title}</span>
                <div>
                    <button id="dft_rst" style="background:#e53935;color:white;border:none;padding:2px 7px;margin-left:5px;border-radius:8px;cursor:pointer;font-size:0.7rem;">${L.reset}</button>
                    <button id="dft_tog" style="background:#4fc3f7;color:white;border:none;padding:2px 7px;margin-left:5px;border-radius:8px;cursor:pointer;font-size:0.7rem;">${L.collapse}</button>
                </div>
            </div>
            <div id="dft_body">
                <div id="dft_status" style="margin-bottom:4px;font-size:0.75rem;"></div>
                <div id="dft_table"></div>
                <div id="dft_hist" style="margin-top:6px;"></div>
            </div>`;
        document.body.appendChild(panelEl);

        panelEl.querySelector("#dft_tog").onclick = () => {
            panelExpanded = !panelExpanded;
            panelEl.querySelector("#dft_body").style.display = panelExpanded ? "" : "none";
            panelEl.querySelector("#dft_tog").textContent = panelExpanded ? L.collapse : L.expand;
        };
        panelEl.querySelector("#dft_rst").onclick = () => {
            if (!confirm(L.confirmReset)) return;
            runHistory = [];
            currentRunGroups = {};
            currentRunBossCounts = {};
            currentRunBossPerGroup = {};
            totalBossCounts = {};
            totalBossPerGroup = {};
            totalRuns = 0;
            isDungeonActive = false;
            reachedFinalWave = false;
            // Clear all dungeon histories
            localStorage.removeItem(STORAGE_KEY);
            render();
        };

        // drag
        let dx, dy, dragging = false;
        const hdr = panelEl.querySelector("#dft_hdr");
        hdr.onmousedown = e => { dragging = true; dx = e.clientX - panelEl.getBoundingClientRect().left; dy = e.clientY - panelEl.getBoundingClientRect().top; e.preventDefault(); };
        document.addEventListener("mousemove", e => { if (!dragging) return; panelEl.style.left = (e.clientX - dx) + "px"; panelEl.style.top = (e.clientY - dy) + "px"; panelEl.style.right = "auto"; });
        document.addEventListener("mouseup", () => { dragging = false; });
    }

    function shouldShow() {
        // Don't show before character is selected
        if (!location.search.includes("characterId")) return false;
        return isDungeonActive || Object.keys(currentRunGroups).length > 0 || runHistory.length > 0;
    }

    function render() {
        ensurePanel();
        if (!panelEl) return;

        if (!shouldShow()) { panelEl.style.display = "none"; return; }
        panelEl.style.display = "";

        const maxWaves = currentDungeon && DUNGEONS[currentDungeon] ? DUNGEONS[currentDungeon].maxWaves : 65;
        const dName = currentDungeon ? dungeonName(currentDungeon) : (isZH ? "地牢" : "Dungeon");
        const labels = allLabels(maxWaves);
        const histAvg = getHistoryAvg();
        const hasHistory = runHistory.length > 0;

        // ── Status ──
        const statusEl = panelEl.querySelector("#dft_status");
        if (isDungeonActive && currentDungeon) {
            const elapsed = dungeonStartTime ? Date.now() - dungeonStartTime : 0;
            if (waitingForCleanGroup) {
                statusEl.innerHTML = `<span style="color:#4fc3f7;">${dName}</span>` +
                    ` <span style="color:#81c784;">${L.wave} ${currentWave}/${maxWaves}</span>` +
                    ` <span style="color:#ff9800;">${L.waitAlign}</span>`;
            } else {
                const partialTag = isPartialRun ? ` <span style="color:#888;font-size:0.65rem;">${L.partial}</span>` : "";
                statusEl.innerHTML = `<span style="color:#4fc3f7;">${dName}</span>` +
                    ` <span style="color:#81c784;">${L.wave} ${currentWave}/${maxWaves}</span>` +
                    ` <span style="color:#ffb74d;">${L.elapsed} ${fmt(elapsed)}</span>${partialTag}`;
            }
        } else {
            statusEl.innerHTML = `<span style="color:#aaa;">${L.waitNext}</span>`;
        }

        // ── Timer table ──
        const tableEl = panelEl.querySelector("#dft_table");
        const hasCurrentData = Object.keys(currentRunGroups).length > 0;

        if (hasCurrentData || isDungeonActive) {
            const hasBossConfig = currentDungeon && DUNGEON_BOSSES[currentDungeon];

            let html = `<table style="width:100%;border-collapse:collapse;font-size:0.75rem;">
                <thead><tr style="text-align:left;color:#4fc3f7;border-bottom:1px solid rgba(255,255,255,0.2);">
                    <th style="padding:2px 4px;">${L.colFloor}</th>
                    <th style="padding:2px 4px;">${L.colTime}</th>`;
            if (hasHistory) {
                html += `<th style="padding:2px 4px;">${L.colAvg}</th>`;
                html += `<th style="padding:2px 4px;">${L.colDiff}</th>`;
            }
            if (hasBossConfig) {
                html += `<th style="padding:2px 4px;color:#ff9800;">${L.colExtra}</th>`;
                if (totalRuns > 0) html += `<th style="padding:2px 4px;color:#ff9800;">${L.colExtraAvg}</th>`;
            }
            html += `</tr></thead><tbody>`;

            let totalTime = 0, totalAvgTime = 0;

            for (const label of labels) {
                const g = currentRunGroups[label];
                if (!g && !isDungeonActive) continue;

                const isActive = isDungeonActive && currentWave >= 0 && groupLabel(currentWave, maxWaves) === label;
                const isFuture = !g && !isActive;

                let groupTime = g ? g.total : 0;
                if (isActive && waveStartTime) groupTime += Date.now() - waveStartTime;

                const rowStyle = isActive ? "color:#ffb74d;" : isFuture ? "color:#555;" : "color:white;";

                html += `<tr style="${rowStyle}">`;
                html += `<td style="padding:2px 4px;">${label}</td>`;
                html += `<td style="padding:2px 4px;">${isFuture ? "-" : fmt(groupTime)}</td>`;

                if (hasHistory) {
                    const ha = histAvg[label];
                    html += `<td style="padding:2px 4px;color:#aaa;">${ha ? fmt(ha.avg) : "-"}</td>`;
                    if (!isFuture && ha && g) {
                        if (!isActive || !waveStartTime) {
                            const diff = groupTime - ha.avg;
                            if (Math.abs(diff) < 1000) html += `<td style="padding:2px 4px;color:#888;">-</td>`;
                            else if (diff > 0) html += `<td style="padding:2px 4px;color:#ef5350;">+${fmtDiff(diff)}</td>`;
                            else html += `<td style="padding:2px 4px;color:#66bb6a;">-${fmtDiff(diff)}</td>`;
                        } else {
                            html += `<td style="padding:2px 4px;color:#888;">...</td>`;
                        }
                    } else {
                        html += `<td style="padding:2px 4px;">-</td>`;
                    }
                }
                if (hasBossConfig) {
                    const bc = currentRunBossPerGroup[label] || 0;
                    const bColor = bc > 0 ? "#ff9800" : "#555";
                    html += `<td style="padding:2px 4px;color:${bColor};">${bc}</td>`;
                    if (totalRuns > 0) {
                        const tb = totalBossPerGroup[label] || 0;
                        html += `<td style="padding:2px 4px;color:#aaa;">${(tb / totalRuns).toFixed(1)}</td>`;
                    }
                }
                html += `</tr>`;
                if (!isFuture) totalTime += groupTime;
                if (hasHistory && histAvg[label]) totalAvgTime += histAvg[label].avg;
            }

            // Total
            html += `<tr style="border-top:1px solid rgba(255,255,255,0.3);color:#4fc3f7;font-weight:bold;">`;
            html += `<td style="padding:2px 4px;">${L.total}</td><td style="padding:2px 4px;">${fmt(totalTime)}</td>`;
            if (hasHistory) {
                html += `<td style="padding:2px 4px;">${fmt(totalAvgTime)}</td>`;
                if (totalTime > 0 && totalAvgTime > 0) {
                    const diff = totalTime - totalAvgTime;
                    if (Math.abs(diff) < 1000) html += `<td style="padding:2px 4px;color:#888;">-</td>`;
                    else if (diff > 0) html += `<td style="padding:2px 4px;color:#ef5350;">+${fmtDiff(diff)}</td>`;
                    else html += `<td style="padding:2px 4px;color:#66bb6a;">-${fmtDiff(diff)}</td>`;
                } else html += `<td style="padding:2px 4px;">-</td>`;
            }
            if (hasBossConfig) {
                const totalBoss = Object.values(currentRunBossPerGroup).reduce((s, c) => s + c, 0);
                html += `<td style="padding:2px 4px;color:#ff9800;">${totalBoss}</td>`;
                if (totalRuns > 0) {
                    const totalHistBoss = Object.values(totalBossPerGroup).reduce((s, c) => s + c, 0);
                    html += `<td style="padding:2px 4px;color:#aaa;">${(totalHistBoss / totalRuns).toFixed(1)}</td>`;
                }
            }
            html += `</tr></tbody></table>`;
            tableEl.innerHTML = html;
        } else {
            tableEl.innerHTML = "";
        }

        // ── History ──
        const histEl = panelEl.querySelector("#dft_hist");
        if (runHistory.length > 0) {
            const recent = runHistory.slice(-5).reverse();
            let h = `<div style="font-size:0.7rem;color:#aaa;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">`;
            const allTotals = runHistory.map(r => Object.values(r.groups).reduce((s, g) => s + g.total, 0));
            const avgTotal = allTotals.reduce((s, t) => s + t, 0) / allTotals.length;
            h += `<span style="color:#4fc3f7;">${L.history} (${runHistory.length} ${L.runs})</span> <span style="color:#81c784;">${L.avgTime}: ${fmt(avgTotal)}</span><br>`;
            for (const run of recent) {
                const total = Object.values(run.groups).reduce((s, g) => s + g.total, 0);
                const t = new Date(run.endTime);
                const ts = `${t.getHours().toString().padStart(2, '0')}:${t.getMinutes().toString().padStart(2, '0')}`;
                const rName = run.dungeonHrid ? dungeonName(run.dungeonHrid) : run.dungeonName;
                h += `[${ts}] ${rName} <span style="color:white;">${fmt(total)}</span><br>`;
            }
            h += `</div>`;
            histEl.innerHTML = h;
        } else {
            histEl.innerHTML = "";
        }
    }

    // ── Game events ──
    function detectDungeon(characterActions) {
        if (!characterActions) return null;
        for (const a of characterActions) {
            if (a?.actionHrid && DUNGEONS[a.actionHrid]) return a.actionHrid;
        }
        return null;
    }

    function tryDetectDungeon() {
        try {
            const d = JSON.parse(localStorage.getItem("init_character_data") || "{}");

            // Check if labyrinth is active — if so, we're NOT in a dungeon
            if (d.labyrinth?.isActive) {
                inLabyrinth = true;
                if (currentDungeon) {
                    if (isDungeonActive) finishRun();
                    currentDungeon = null;
                    isDungeonActive = false;
                    render();
                }
                return;
            }
            inLabyrinth = false;

            let hrid = detectDungeon(d.characterActions);
            if (!hrid && d.partyInfo?.partyActionMap) {
                for (const a of Object.values(d.partyInfo.partyActionMap)) {
                    if (a?.actionHrid && DUNGEONS[a.actionHrid]) { hrid = a.actionHrid; break; }
                }
            }
            if (hrid) {
                switchDungeon(hrid);
            } else {
                // Not in a dungeon (labyrinth, regular combat, etc.) — clear state
                if (currentDungeon) {
                    if (isDungeonActive) finishRun();
                    currentDungeon = null;
                    isDungeonActive = false;
                    render();
                }
            }
        } catch (e) {}
    }

    function onNewBattle(msg) {
        const wave = msg.wave;
        if (wave === undefined || wave === null) return;

        // Skip labyrinth battles
        if (inLabyrinth) return;

        tryDetectDungeon();
        if (!currentDungeon || !DUNGEONS[currentDungeon]) return;

        const maxWaves = DUNGEONS[currentDungeon].maxWaves;
        const now = Date.now();

        if (wave === 1) {
            if (isDungeonActive && Object.keys(currentRunGroups).length > 0) finishRun();
            currentRunGroups = {};
            currentRunBossCounts = {};
            currentRunBossPerGroup = {};
            dungeonStartTime = now;
            currentWave = -1;
            waveStartTime = null;
            isDungeonActive = true;
            waitingForCleanGroup = false;
            isPartialRun = false;
            reachedFinalWave = false;
        } else if (!isDungeonActive) {
            isDungeonActive = true;
            dungeonStartTime = null;
            currentRunGroups = {};
            currentRunBossCounts = {};
            currentRunBossPerGroup = {};
            currentWave = -1;
            waveStartTime = null;
            isPartialRun = true;
            reachedFinalWave = false;
            waitingForCleanGroup = !isGroupStart(wave);
            if (!waitingForCleanGroup) dungeonStartTime = now;
        }

        // Mark when we reach the final wave (boss wave)
        if (wave === maxWaves) reachedFinalWave = true;

        detectBosses(msg);

        if (waitingForCleanGroup && isGroupStart(wave)) {
            waitingForCleanGroup = false;
            dungeonStartTime = now;
            currentWave = wave;
            waveStartTime = now;
            render();
            return;
        }

        if (!waitingForCleanGroup && waveStartTime !== null && currentWave >= 0) {
            const elapsed = now - waveStartTime;
            const label = groupLabel(currentWave, maxWaves);
            if (!currentRunGroups[label]) currentRunGroups[label] = { total: 0, count: 0 };
            currentRunGroups[label].total += elapsed;
            currentRunGroups[label].count += 1;
        }

        currentWave = wave;
        waveStartTime = now;
        render();
    }

    function finishRun() {
        if (waveStartTime !== null && currentWave >= 0 && currentDungeon && DUNGEONS[currentDungeon]) {
            const elapsed = Date.now() - waveStartTime;
            const maxWaves = DUNGEONS[currentDungeon].maxWaves;
            const label = groupLabel(currentWave, maxWaves);
            if (!currentRunGroups[label]) currentRunGroups[label] = { total: 0, count: 0 };
            currentRunGroups[label].total += elapsed;
            currentRunGroups[label].count += 1;
        }

        if (Object.keys(currentRunGroups).length > 0 && !isPartialRun && reachedFinalWave) {
            runHistory.push({
                dungeonHrid: currentDungeon,
                dungeonName: dungeonName(currentDungeon),
                maxWaves: DUNGEONS[currentDungeon]?.maxWaves || 65,
                groups: JSON.parse(JSON.stringify(currentRunGroups)),
                endTime: Date.now(),
            });

            for (const [hrid, count] of Object.entries(currentRunBossCounts)) {
                if (!totalBossCounts[hrid]) totalBossCounts[hrid] = 0;
                totalBossCounts[hrid] += count;
            }
            for (const [label, count] of Object.entries(currentRunBossPerGroup)) {
                if (!totalBossPerGroup[label]) totalBossPerGroup[label] = 0;
                totalBossPerGroup[label] += count;
            }
            totalRuns++;
            saveHistory();
        }

        currentRunGroups = {};
        currentRunBossCounts = {};
        currentRunBossPerGroup = {};
        isDungeonActive = false;
        waveStartTime = null;
        currentWave = -1;
        reachedFinalWave = false;
        render();
    }

    function switchDungeon(newDungeon) {
        if (newDungeon === currentDungeon) return;
        // Save current dungeon's history before switching
        if (currentDungeon) saveHistory();
        // Reset current run state
        if (isDungeonActive) {
            currentRunGroups = {};
            currentRunBossCounts = {};
            currentRunBossPerGroup = {};
            isDungeonActive = false;
            waveStartTime = null;
            currentWave = -1;
            reachedFinalWave = false;
        }
        currentDungeon = newDungeon;
        // Load the new dungeon's history
        loadHistoryFor(newDungeon);
    }

    function handle(message) {
        if (message.type === "init_character_data") {
            // Track labyrinth state
            if (message.labyrinth?.isActive) {
                inLabyrinth = true;
                if (currentDungeon) {
                    if (isDungeonActive) finishRun();
                    currentDungeon = null;
                    isDungeonActive = false;
                }
                render();
                return;
            }
            inLabyrinth = false;

            let d = detectDungeon(message.characterActions);
            if (!d && message.partyInfo?.partyActionMap) {
                for (const a of Object.values(message.partyInfo.partyActionMap)) {
                    if (a?.actionHrid && DUNGEONS[a.actionHrid]) { d = a.actionHrid; break; }
                }
            }
            if (d) {
                switchDungeon(d);
            } else if (currentDungeon) {
                if (isDungeonActive) finishRun();
                currentDungeon = null;
                isDungeonActive = false;
            }
            render();
        }

        // Track labyrinth enter/exit from dedicated messages
        if (message.type === "labyrinth_update" || message.type === "labyrinth_enter") {
            inLabyrinth = true;
            if (currentDungeon) {
                if (isDungeonActive) finishRun();
                currentDungeon = null;
                isDungeonActive = false;
                render();
            }
        }
        if (message.type === "labyrinth_exit") {
            inLabyrinth = false;
        }

        if (message.type === "new_battle") onNewBattle(message);

        if (message.type === "chat_message_received" &&
            message.message?.chan === "/chat_channel_types/party" &&
            message.message?.isSystemMessage) {
            const m = message.message.m;
            if (m === "systemChatMessage.partyBattleEnded" ||
                m === "systemChatMessage.partyBattleStopped") {
                if (isDungeonActive) finishRun();
            }
            if (m === "systemChatMessage.partyBattleStarted") tryDetectDungeon();
        }
    }

    // ── WebSocket wrap ──
    const OrigWS = unsafeWindow.WebSocket;
    const WrapWS = function (...args) {
        const ws = new OrigWS(...args);
        ws.addEventListener("message", e => {
            try { handle(JSON.parse(e.data)); } catch (_) {}
        });
        return ws;
    };
    WrapWS.CONNECTING = OrigWS.CONNECTING;
    WrapWS.OPEN = OrigWS.OPEN;
    WrapWS.CLOSED = OrigWS.CLOSED;
    unsafeWindow.WebSocket = WrapWS;

    setInterval(() => {
        updateLang();
        if (isDungeonActive && panelEl) render();
    }, 2000);
    (function wait() {
        if (document.body) {
            loadHistory();
            updateLang();
            ensurePanel();
            render();
        } else {
            setTimeout(wait, 500);
        }
    })();
})();