Dungeon Floor Timer

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Dungeon Floor Timer
// @name:zh-CN   地牢计时器
// @name:zh-TW   地牢計時器
// @namespace    http://tampermonkey.net/
// @version      1.8
// @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: "重置",
        collapse: "收起",
        expand: "展开",
        wave: "波次",
        elapsed: "已用",
        waitAlign: "等待下一组开始计时...",
        waitNext: "等待下一轮...",
        partial: "(不完整轮)",
        colFloor: "层数",
        colTime: "用时",
        colAvg: "均时",
        colDiff: "对比",
        colExtra: "额外",
        colExtraAvg: "平均",
        total: "总计",
        history: "历史",
        runs: "轮",
        avgTime: "平均",
    };
    const enStrings = {
        title: "⏱ Dungeon Timer",
        reset: "Reset",
        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;

    // ── Persistence ──
    const STORAGE_KEY = "dft_history";

    function saveHistory() {
        try {
            const data = {
                runHistory,
                totalBossCounts,
                totalBossPerGroup,
                totalRuns,
            };
            localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
        } catch (_) {}
    }

    function loadHistory() {
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            if (!raw) return;
            const data = JSON.parse(raw);
            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 = () => {
            runHistory = [];
            currentRunGroups = {};
            currentRunBossCounts = {};
            currentRunBossPerGroup = {};
            totalBossCounts = {};
            totalBossPerGroup = {};
            totalRuns = 0;
            isDungeonActive = false;
            saveHistory();
            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() {
        if (currentDungeon) return;
        try {
            const d = JSON.parse(localStorage.getItem("init_character_data") || "{}");
            const hrid = detectDungeon(d.characterActions);
            if (hrid) currentDungeon = hrid;
            if (!currentDungeon && d.partyInfo?.partyActionMap) {
                for (const a of Object.values(d.partyInfo.partyActionMap)) {
                    if (a?.actionHrid && DUNGEONS[a.actionHrid]) { currentDungeon = a.actionHrid; break; }
                }
            }
        } catch (e) {}
    }

    function onNewBattle(msg) {
        const wave = msg.wave;
        if (wave === undefined || wave === null) 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;
        } else if (!isDungeonActive) {
            isDungeonActive = true;
            dungeonStartTime = null;
            currentRunGroups = {};
            currentRunBossCounts = {};
            currentRunBossPerGroup = {};
            currentWave = -1;
            waveStartTime = null;
            isPartialRun = true;
            waitingForCleanGroup = !isGroupStart(wave);
            if (!waitingForCleanGroup) dungeonStartTime = now;
        }

        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) {
            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;
        render();
    }

    function handle(message) {
        if (message.type === "init_character_data") {
            const d = detectDungeon(message.characterActions);
            if (d) currentDungeon = d;
            if (message.partyInfo?.partyActionMap) {
                for (const a of Object.values(message.partyInfo.partyActionMap)) {
                    if (a?.actionHrid && DUNGEONS[a.actionHrid]) { currentDungeon = a.actionHrid; break; }
                }
            }
            render();
        }

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