Ranged Way Idle

一些超级有用的MWI的QoL功能

目前為 2025-10-17 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Ranged Way Idle
// @namespace    http://tampermonkey.net/
// @version      4.2
// @description  一些超级有用的MWI的QoL功能
// @author       AlphB
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @grant        GM_notification
// @grant        GM.xmlHttpRequest
// @connect      https://www.milkywayidle.com/*
// @connect      https://test.milkywayidle.com/*
// @icon         https://tupian.li/images/2025/09/30/68dae3cf1fa7e.png
// @license      CC-BY-NC-SA-4.0
// ==/UserScript==

(function () {
    const configs = {
        // combat
        notifyCombatDeath: {
            type: "switch",
            value: true,
            trigger: ["ws", "init"],
            listenMessageTypes: ["new_battle", "battle_updated"]
        },
        minimumNotifyCooldownSeconds: {type: "input_number", value: 5, trigger: [],},

        // message
        notifyChatMessages: {
            type: "switch",
            value: true,
            trigger: ["ws", "ob", "init"],
            listenMessageTypes: ["chat_message_received"]
        },
        notifyChatMessagesVolume: {type: "input_range", value: 0.5, trigger: [], min: 0, max: 1, step: 0.01},
        notifyChatMessagesByRegex: {type: "switch", value: false, trigger: []},
        notifyChatMessagesFilterSelf: {type: "switch", value: true, trigger: []},

        // info
        initCharacterData: {
            type: "switch",
            value: true,
            trigger: ["ws"],
            listenMessageTypes: ["init_character_data"],
            isHidden: true
        },
        updateLocalStorageMarketPrice: {
            type: "switch",
            value: true,
            trigger: ["ws"],
            listenMessageTypes: ["market_item_order_books_updated"]
        },
        showTaskValue: {
            type: "switch",
            value: true,
            trigger: ["ws", "ob", "init"],
            listenMessageTypes: ["quests_updated"]
        },
        trackLeaderBoardData: {type: "switch", value: true, trigger: ["ob"]},

        // UI
        autoClickTaskSortButton: {type: "switch", value: true, trigger: ["ob"]},
        showMarketAPIUpdateTime: {type: "switch", value: true, trigger: ["ob"]},
        forceUpdateAPIButton: {type: "switch", value: true, trigger: ["ob"]},
        disableQueueUpgradeButton: {type: "switch", value: false, trigger: ["ob"]},
        disableActionQueueBar: {type: "switch", value: false, trigger: ["ob"]},

        // listing
        hookListingInfo: {
            type: "switch",
            value: true,
            trigger: ["ws"],
            listenMessageTypes: ["market_listings_updated", "init_character_data"],
            isHidden: true
        },
        showTotalListingFunds: {
            type: "switch",
            value: true,
            trigger: ["ws", "ob"],
            listenMessageTypes: ["market_listings_updated"]
        },
        showTotalListingFundsPrecise: {type: "input_number", value: 0, trigger: []},
        showListingInfo: {
            type: "switch",
            value: true,
            trigger: ["ws", "ob"],
            listenMessageTypes: ["market_listings_updated"]
        },
        showListingPricePrecise: {type: "input_number", value: 2, trigger: []},
        showListingCreateTimeByLifespan: {type: "switch", value: false, trigger: []},
        listingSortTools: {type: "switch", value: false, isHidden: true, trigger: ["ob"]}, // TO DO
        notifyListingFilled: {
            type: "switch",
            value: false,
            trigger: ["ws"],
            listenMessageTypes: ["market_listings_updated"]
        },
        notifyListingFilledVolume: {type: "input_range", value: 0.5, trigger: [], min: 0, max: 1, step: 0.01},
        estimateListingCreateTime: {
            type: "switch",
            value: true,
            trigger: ["ws", "ob"],
            listenMessageTypes: ["market_item_order_books_updated"]
        },
        estimateListingCreateTimeColorByAccuracy: {type: "switch", value: false, trigger: []},
        estimateListingCreateTimeColorByLifespan: {type: "switch", value: false, trigger: []},

        // other
        mournForMagicWayIdle: {type: "switch", value: true, trigger: ["init"]},
        optimizeDocumentObserver: {type: "switch", value: false, trigger: []},
        debugPrintWSMessages: {type: "switch", value: false, trigger: [], listenMessageTypes: []},
        showConfigMenu: {type: "switch", value: true, trigger: ["ob"], isHidden: true},
        // testConfig: {type: "switch", value: true, trigger: [], isHidden: true, isSecret: false},
    }
    const globalVariables = {
        marketAPIUrl: document.URL.includes("test.milkywayidle.com") ?
            "https://test.milkywayidle.com/game_data/marketplace.json" :
            "https://www.milkywayidle.com/game_data/marketplace.json",
        initCharacterData: null,
        documentObserver: null,
        documentObserverFunction: null,
        webSocketMessageProcessor: null,
        functionMap: {},
        language: "zh-cn",
        notifyMessageAudio: new Audio("https://upload.thbwiki.cc/d/d1/se_bonus2.mp3"),
        notifyListingFilledAudio: new Audio("https://upload.thbwiki.cc/f/ff/se_trophy.mp3"),
        allListings: {}
    };
    unsafeWindow._rwivb = globalVariables;

    const I18NMap = {
        "ranged_way_idle_config_menu_title": {"zh-cn": "设置"},
        "notifyCombatDeath": {"zh-cn": "战斗中角色死亡时,发出通知"},
        "minimumNotifyCooldownSeconds": {"zh-cn": "角色死亡通知冷却时间(秒)"},
        "notifyChatMessages": {"zh-cn": "聊天消息含有关键词时,发出声音提醒"},
        "notifyChatMessagesVolume": {"zh-cn": "聊天消息声音提醒音量"},
        "notifyChatMessagesByRegex": {"zh-cn": "聊天消息采用正则匹配"},
        "notifyChatMessagesFilterSelf": {"zh-cn": "不提醒自己发送的聊天消息"},
        "updateLocalStorageMarketPrice": {"zh-cn": "更新localStorage中的市场价格"},
        "showTaskValue": {"zh-cn": "显示任务期望收益(依赖 食用工具)"},
        "trackLeaderBoardData": {"zh-cn": "跟踪排行榜数据"},
        "autoClickTaskSortButton": {"zh-cn": "自动点击任务排序按钮(依赖 MWI TaskManager)"},
        "showMarketAPIUpdateTime": {"zh-cn": "显示市场API更新时间"},
        "forceUpdateAPIButton": {"zh-cn": "强制更新市场API按钮"},
        "disableQueueUpgradeButton": {"zh-cn": "禁用各处队列升级按钮,以防跳转至牛铃商店"},
        "disableActionQueueBar": {"zh-cn": "禁用行动队列提示框显示"},
        "showTotalListingFunds": {"zh-cn": "显示市场挂单的总购买预付金/出售可获金/待领取金额"},
        "showTotalListingFundsPrecise": {"zh-cn": "显示市场挂单的总购买预付金/出售可获金/待领取金额的精度"},
        "showListingInfo": {"zh-cn": "显示各个挂单的价格、创建时间信息"},
        "showListingPricePrecise": {"zh-cn": "各个挂单的购买预付金/出售可获金的价格精度"},
        "showListingCreateTimeByLifespan": {"zh-cn": "显示挂单已存在时长,而非创建的时刻"},
        "notifyListingFilled": {"zh-cn": "挂单完成时,发出声音提醒"},
        "notifyListingFilledVolume": {"zh-cn": "挂单完成声音提醒音量"},
        "estimateListingCreateTime": {"zh-cn": "依据挂单ID线性估算挂单创建时间"},
        "estimateListingCreateTimeColorByAccuracy": {"zh-cn": "依据精度为挂单创建时间着色(越偏向绿色 精度越高)"},
        "estimateListingCreateTimeColorByLifespan": {"zh-cn": "依据存在时间为挂单创建时间着色(越偏向绿色 创建时间越短)该项为真时,覆盖上一选项设置"},
        "mournForMagicWayIdle": {"zh-cn": "在控制台为Magic Way Idle默哀"},
        "optimizeDocumentObserver": {"zh-cn": "优化document监听器,减少性能开销(可能有bug,出现问题请关闭)"},
        "debugPrintWSMessages": {"zh-cn": "打印WebSocket消息(不推荐打开)"},

        "configNoteText": {"zh-cn": "部分设置可能需要刷新页面才能生效。如果完全无效,或者控制台大量报错,请尝试更新本插件或前置插件"},
        "notifyChatMessagesAddRowButton": {"zh-cn": "添加聊天消息监听关键词"},
        "taskExpectedValueText": {"zh-cn": "任务期望收益:"},
        "trackLeaderBoardDataLeaderboardStoreButton": {"zh-cn": "记录当前排行榜数据"},
        "trackLeaderBoardDataLeaderboardDeleteButton": {"zh-cn": "删除本地数据"},
        "trackLeaderBoardDataLeaderboardRecordTimeText": {"zh-cn": "本地数据记录于:${recordTime}(${timeDelta}小时前)"},
        "trackLeaderBoardDataLeaderboardNoRecordTimeText": {"zh-cn": "无本地数据记录"},
        "trackLeaderBoardDataNoteText": {"zh-cn": "由于排行榜数据每20分钟记录一次,增速和超越时间有误差,仅供参考。"},
        "trackLeaderBoardDataDifference": {"zh-cn": "增量"},
        "trackLeaderBoardDataSpeed": {"zh-cn": "增速"},
        "trackLeaderBoardDataCatchupTime": {"zh-cn": "超越时间"},
        "trackLeaderBoardDataCatchupTimeNow": {"zh-cn": "现在!"},
        "trackLeaderBoardDataNewRecordText": {"zh-cn": "新上榜"},
        "showMarketAPIUpdateTimeText": {"zh-cn": "市场API更新时间于:"},
        "forceUpdateAPIButtonText": {"zh-cn": "强制更新市场API"},
        "forceUpdateAPIButtonTextSuccess": {"zh-cn": "更新成功。市场数据更新于"},
        "forceUpdateAPIButtonTextError": {"zh-cn": "更新失败。请稍后重试。"},
        "forceUpdateAPIButtonTextTimeout": {"zh-cn": "更新超时。请稍后重试。"},
        "totalUnclaimedCoinsText": {"zh-cn": "待领取金额"},
        "totalPrepaidCoinsText": {"zh-cn": "购买预付金"},
        "totalSellResultCoinsText": {"zh-cn": "出售可获金"},
        "showListingInfoCreateTimeAt": {"zh-cn": "创建于"},
        "showListingInfoCreateTimeLifespan": {"zh-cn": "已存在"},
        "showListingInfoTopOrderPriceText": {"zh-cn": "左一/右一 价格"},
        "showListingInfoTotalPriceText": {"zh-cn": "购买预付金/出售可获金"},
        "estimateListingCreateTimeText": {"zh-cn": "估计创建时间"},

        "/chat_channel_types/general": {"zh-cn": "英语"},
        "/chat_channel_types/chinese": {"zh-cn": "中文"},
        "/chat_channel_types/ironcow": {"zh-cn": "铁牛"},
        "/chat_channel_types/trade": {"zh-cn": "交易"},
        "/chat_channel_types/recruit": {"zh-cn": "招募"},
        "/chat_channel_types/beginner": {"zh-cn": "新手"},
        "/chat_channel_types/guild": {"zh-cn": "公会"},
        "/chat_channel_types/party": {"zh-cn": "队伍"},
        "/chat_channel_types/whisper": {"zh-cn": "私聊"},
        "/chat_channel_types/moderator": {"zh-cn": "管理员"},

        "/chat_channel_types/arabic": {"zh-cn": "العربية"},
        "/chat_channel_types/french": {"zh-cn": "Français"},
        "/chat_channel_types/german": {"zh-cn": "Deutsch"},
        "/chat_channel_types/hebrew": {"zh-cn": "עברית"},
        "/chat_channel_types/hindi": {"zh-cn": "हिंदी"},
        "/chat_channel_types/japanese": {"zh-cn": "日本語"},
        "/chat_channel_types/korean": {"zh-cn": "한국어"},
        "/chat_channel_types/portuguese": {"zh-cn": "Português"},
        "/chat_channel_types/russian": {"zh-cn": "Русский"},
        "/chat_channel_types/spanish": {"zh-cn": "Español"},
        "/chat_channel_types/vietnamese": {"zh-cn": "Tiếng Việt"},

    };

    function initScript() {
        const allFunctionsObject = new AllFunctions();
        for (const configName in configs) {
            if (configs[configName].trigger.length === 0) continue;
            if (!allFunctionsObject[configName]) {
                console.warn("No function found for config: " + configName);
                continue;
            }
            globalVariables.functionMap[configName] = allFunctionsObject[configName]();
        }
        globalVariables.functionMap["showConfigMenu"].loadLocalConfig();

        hookWebSocket();
        initDocumentObserver();

        for (const configName in configs) {
            if (globalVariables.functionMap[configName] && configs[configName].trigger.includes('init')) {
                try {
                    globalVariables.functionMap[configName].init();
                } catch (err) {
                    console.error(err);
                }
            }
        }

        function hookWebSocket() {
            // message processor
            globalVariables.webSocketMessageProcessor = function (message, type) {
                const obj = JSON.parse(message);
                if (configs.debugPrintWSMessages.value) console.log(type, obj);
                if (type !== 'get' || !obj) return;
                const messageType = obj.type;
                for (const configName in configs) {
                    if (configs[configName].type !== 'switch' || !configs[configName].value) continue;
                    if (globalVariables.functionMap[configName] && configs[configName].trigger.includes('ws') &&
                        configs[configName].listenMessageTypes && configs[configName].listenMessageTypes.includes(messageType)) {
                        try {
                            globalVariables.functionMap[configName].ws(obj);
                        } catch (err) {
                            console.error(err);
                        }
                    }
                }
            };

            // get
            const oriGet = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data").get;

            function hookedGet() {
                const socket = this.currentTarget;
                if (!(socket instanceof WebSocket) || !socket.url) {
                    return oriGet.call(this);
                }
                const message = oriGet.call(this);
                try {
                    globalVariables.webSocketMessageProcessor(message, 'get')
                } catch (err) {
                    console.error(err);
                }
                return message;
            }

            Object.defineProperty(MessageEvent.prototype, "data", {
                get: hookedGet,
                configurable: true,
                enumerable: true
            });

            // send
            const originalSend = WebSocket.prototype.send;

            WebSocket.prototype.send = function (message) {
                try {
                    globalVariables.webSocketMessageProcessor(message, 'send');
                } catch (err) {
                    console.error(err);
                }
                return originalSend.call(this, message);
            };
        }

        function initDocumentObserver() {
            globalVariables.documentObserverFunction = function documentObserverFunction(mutationsList, observer) {
                const node = configs.optimizeDocumentObserver.enable ? mutationsList[0].target : document;
                for (const configName in configs) {
                    if (configs[configName].type !== 'switch' || !configs[configName].value) continue;
                    if (globalVariables.functionMap[configName] && configs[configName].trigger.includes('ob')) {
                        try {
                            globalVariables.functionMap[configName].ob(node);
                        } catch (err) {
                            console.error(err);
                        }
                    }
                }
            }
            globalVariables.documentObserver = new MutationObserver(globalVariables.documentObserverFunction);
            globalVariables.documentObserver.observe(document, {childList: true, subtree: true});
        }
    }

    class AllFunctions {
        showConfigMenu() {
            function loadLocalConfig() {
                // delete old version config
                delete localStorage["ranged_way_idle_config"];

                const localConfig = localStorage.getItem("ranged_way_idle_configs");
                const localConfigObject = localConfig ? JSON.parse(localConfig) : {};
                for (const configName in localConfigObject) {
                    if (configs[configName]) {
                        configs[configName].value = localConfigObject[configName];
                    }
                }
            }

            function saveLocalConfig() {
                const localConfig = localStorage.getItem("ranged_way_idle_configs");
                const localConfigObject = localConfig ? JSON.parse(localConfig) : {};
                for (const configName in configs) {
                    localConfigObject[configName] = configs[configName].value;
                }
                localStorage.setItem("ranged_way_idle_configs", JSON.stringify(localConfigObject));
            }

            function setConfig(configName, value) {
                // forbid changing hidden config
                if (configs[configName].isHidden) return;

                configs[configName].value = value;
                saveLocalConfig();
            }

            function ob(node) {
                const settingPanelNode = node.querySelector(".SettingsPanel_profileTab__214Bj");
                if (!settingPanelNode) return;
                if (settingPanelNode.querySelector(".RangedWayIdleConfigMenuRoot")) return;
                const configMenuRootNode = document.createElement("div");
                configMenuRootNode.classList.add("RangedWayIdleConfigMenuRoot");
                configMenuRootNode.style.display = "flex";
                configMenuRootNode.style.flexDirection = "column";

                // head
                const headNode = document.createElement("div");
                const headSpanNode1 = document.createElement("span");
                headSpanNode1.textContent = "Ranged Way Idle";
                headSpanNode1.style.fontSize = "1.5rem";
                headSpanNode1.style.color = "#66CCFF";
                headNode.appendChild(headSpanNode1);
                const headSpanNode2 = document.createElement("span");
                headSpanNode2.textContent = I18N("ranged_way_idle_config_menu_title");
                headSpanNode2.style.fontSize = "1.5rem";
                headNode.appendChild(headSpanNode2);
                configMenuRootNode.appendChild(headNode);

                // note text
                const noteTextNode = document.createElement("div");
                noteTextNode.textContent = I18N("configNoteText");
                configMenuRootNode.appendChild(noteTextNode);

                // if contains secret setting, add additional text
                if (Object.values(configs).some(config => config.isSecret)) {
                    // 没错我就是有隐藏功能不给大伙用,不服你就憋着嘿嘿嘿 ᗜˬᗜ
                    const secretTextNode = document.createElement("div");
                    secretTextNode.innerHTML = `<span style="color:#66CCFF">天依蓝</span>为内部功能,严禁外传!截图也不行!`;
                    configMenuRootNode.appendChild(secretTextNode);
                }

                // body
                for (const configName in configs) {
                    if (configs[configName].isHidden) continue;
                    const divNode = document.createElement("div");
                    divNode.style.display = "flex";
                    divNode.style.alignItems = "center";
                    if (configs[configName].type === "switch") {
                        const inputNode = document.createElement("input");
                        inputNode.type = "checkbox";
                        inputNode.checked = configs[configName].value;
                        inputNode.addEventListener("change", () => {
                            setConfig(configName, inputNode.checked);
                        });
                        inputNode.id = configName;
                        divNode.appendChild(inputNode);

                        const textNode = document.createElement("span");
                        textNode.textContent = I18N(configName);
                        if (configs[configName].isSecret) {
                            textNode.style.color = "#66CCFF";
                        }
                        divNode.appendChild(textNode);

                    } else if (configs[configName].type === "input_number") {
                        const textNode = document.createElement("span");
                        textNode.textContent = I18N(configName);
                        if (configs[configName].isSecret) {
                            textNode.style.color = "#66CCFF";
                        }
                        divNode.appendChild(textNode);

                        const inputNode = document.createElement("input");
                        inputNode.type = "number";
                        inputNode.value = configs[configName].value;
                        inputNode.addEventListener("change", () => {
                            setConfig(configName, Number(inputNode.value));
                        });
                        inputNode.id = configName;
                        inputNode.style.width = "5rem";
                        divNode.appendChild(inputNode);
                    } else if (configs[configName].type === "input_range") {
                        const textNode = document.createElement("span");
                        textNode.textContent = I18N(configName);
                        if (configs[configName].isSecret) {
                            textNode.style.color = "#66CCFF";
                        }
                        divNode.appendChild(textNode);

                        const inputNode = document.createElement("input");
                        inputNode.type = "range";
                        inputNode.value = configs[configName].value;
                        inputNode.min = configs[configName].min;
                        inputNode.max = configs[configName].max;
                        inputNode.step = configs[configName].step;
                        inputNode.addEventListener("change", () => {
                            setConfig(configName, Number(inputNode.value));
                        });
                        inputNode.id = configName;
                        inputNode.style.width = "10rem";
                        divNode.appendChild(inputNode);
                    }
                    configMenuRootNode.appendChild(divNode);
                }

                // add to panel
                settingPanelNode.appendChild(configMenuRootNode);
            }


            return {loadLocalConfig: loadLocalConfig, ob: ob};
        }

        notifyCombatDeath() {
            const players = [];
            let lastNotificationTime = 0;

            function newBattle(obj) {
                players.length = 0;
                for (const player of obj.players) {
                    players.push({
                        name: player.name,
                        isAlive: player.currentHitpoints > 0
                    });
                    if (player.currentHitpoints === 0) {
                        new Notification('战斗提醒', {body: `${player.name} 死了!`});
                    }
                }
            }

            function battleUpdated(obj) {
                for (const playerIndex in obj.pMap) {
                    const player = players[playerIndex];
                    if (player.isAlive && obj.pMap[playerIndex].cHP === 0 &&
                        Date.now() - lastNotificationTime > 1000 * configs.minimumNotifyCooldownSeconds.value) {
                        new Notification('战斗提醒', {body: `${player.name} 死了!`});
                        lastNotificationTime = Date.now();
                    }
                    player.isAlive = obj.pMap[playerIndex].cHP > 0;
                }
            }

            function ws(obj) {
                if (obj.type === "new_battle") {
                    newBattle(obj);
                } else if (obj.type === "battle_updated") {
                    battleUpdated(obj);
                }
            }

            function init() {
                Notification.requestPermission();
            }

            return {ws: ws, init: init};
        }

        notifyChatMessages() {
            const allChannels = [
                "/chat_channel_types/chinese",
                "/chat_channel_types/general",
                "/chat_channel_types/ironcow",
                "/chat_channel_types/trade",
                "/chat_channel_types/recruit",
                "/chat_channel_types/beginner",
                "/chat_channel_types/guild",
                "/chat_channel_types/party",
                "/chat_channel_types/whisper",
                "/chat_channel_types/moderator",

                "/chat_channel_types/arabic",
                "/chat_channel_types/french",
                "/chat_channel_types/german",
                "/chat_channel_types/hebrew",
                "/chat_channel_types/hindi",
                "/chat_channel_types/japanese",
                "/chat_channel_types/korean",
                "/chat_channel_types/portuguese",
                "/chat_channel_types/russian",
                "/chat_channel_types/spanish",
                "/chat_channel_types/vietnamese",
            ];
            let listenObject = {};
            let messageListerMenuRootNode;

            function createNewRow(selectedChannel = "", inputText = "") {
                const listenRow = document.createElement("div");
                listenRow.classList.add("RangedWayIdleMessageListenRow");

                // channel select
                const selectNode = document.createElement('select');
                allChannels.forEach(channel => {
                    const option = document.createElement('option');
                    option.value = channel;
                    option.textContent = I18N(channel);
                    if (channel === selectedChannel) {
                        option.selected = true;
                    }
                    selectNode.appendChild(option);
                });
                selectNode.addEventListener('change', updateListenObject);

                // input text
                const inputNode = document.createElement('input');
                inputNode.type = 'text';
                inputNode.value = inputText;
                inputNode.addEventListener('input', updateListenObject);

                // delete button
                const deleteButton = document.createElement('button');
                deleteButton.textContent = "×";
                deleteButton.addEventListener('click', function () {
                    listenRow.remove();
                    updateListenObject();
                });
                deleteButton.style.backgroundColor = "#F44444";

                // add to row
                listenRow.appendChild(selectNode);
                listenRow.appendChild(inputNode);
                listenRow.appendChild(deleteButton);

                return listenRow;
            }

            function updateListenObject() {
                const newListenObject = {};
                for (const channel of allChannels) {
                    newListenObject[channel] = [];
                }

                // collect channel and text from rows
                for (const row of messageListerMenuRootNode.querySelectorAll('.RangedWayIdleMessageListenRow')) {
                    const channel = row.querySelector('select').value;
                    const text = row.querySelector('input').value.trim();
                    newListenObject[channel].push(text);
                }

                listenObject = newListenObject;
                localStorage.setItem("ranged_way_idle_listen_chat_messages", JSON.stringify(listenObject));
            }

            function ws(obj) {
                if (obj.type === "chat_message_received") {
                    const channel = obj.message.chan;
                    const text = obj.message.m;
                    if (configs.notifyChatMessagesFilterSelf.value && obj.message.cId === globalVariables.initCharacterData.character.id) return;
                    if (!listenObject[channel]) return;
                    for (const listenText of listenObject[channel]) {
                        if (configs.notifyChatMessagesByRegex.value) {
                            const regex = new RegExp(listenText, "g");
                            if (regex.test(text)) {
                                globalVariables.notifyMessageAudio.volume = configs.notifyChatMessagesVolume.value;
                                globalVariables.notifyMessageAudio.play();
                                break;
                            }
                        } else {
                            if (text.includes(listenText)) {
                                globalVariables.notifyMessageAudio.volume = configs.notifyChatMessagesVolume.value;
                                globalVariables.notifyMessageAudio.play();
                                break;
                            }
                        }
                    }

                }
            }

            function ob(node) {
                // add this after config menu
                const configMenuRootNode = node.querySelector(".RangedWayIdleConfigMenuRoot");
                if (!configMenuRootNode) return;
                if (node.querySelector(".RangedWayIdleMessageListerMenu")) return;
                messageListerMenuRootNode = document.createElement("div");
                messageListerMenuRootNode.classList.add("RangedWayIdleMessageListerMenu");

                // new row button
                const addNewRowButton = document.createElement("button");
                addNewRowButton.textContent = I18N("notifyChatMessagesAddRowButton");
                addNewRowButton.addEventListener("click", () => {
                    messageListerMenuRootNode.appendChild(createNewRow());
                });
                addNewRowButton.style.backgroundColor = "#66CCFF";
                addNewRowButton.style.color = "#000000";
                messageListerMenuRootNode.appendChild(addNewRowButton);

                // load local listeners
                for (const channel of allChannels) {
                    if (listenObject[channel]) {
                        for (const text of listenObject[channel]) {
                            messageListerMenuRootNode.appendChild(createNewRow(channel, text));
                        }
                    }
                }

                configMenuRootNode.insertAdjacentElement("afterend", messageListerMenuRootNode);
            }

            function init() {
                const localListenObject = localStorage.getItem("ranged_way_idle_listen_chat_messages");
                if (localListenObject) {
                    listenObject = JSON.parse(localListenObject);
                }
            }

            return {ws: ws, ob: ob, init: init};
        }

        initCharacterData() {
            function ws(obj) {
                globalVariables.initCharacterData = obj;
            }

            return {ws: ws};
        }

        updateLocalStorageMarketPrice() {
            function ws(obj) {
                if (obj.type === "market_item_order_books_updated") {
                    const localMarketAPIJson = JSON.parse(localStorage.getItem('MWITools_marketAPI_json'));
                    const itemHrid = obj.marketItemOrderBooks.itemHrid;
                    const orderBooks = obj.marketItemOrderBooks.orderBooks;
                    for (let enhanceLevel = 0; enhanceLevel <= 20; enhanceLevel++) {
                        if (orderBooks[enhanceLevel]) {
                            // 如果左右至少有一个挂单,则需要更新为该价格
                            let askValue = -1;
                            const ask = orderBooks[enhanceLevel].asks;
                            if (ask && ask.length) {
                                askValue = Math.min(...ask.map(listing => listing.price));
                            }
                            let bidValue = -1;
                            const bid = orderBooks[enhanceLevel].bids;
                            if (bid && bid.length) {
                                bidValue = Math.max(...bid.map(listing => listing.price));
                            }

                            if (askValue !== -1 || bidValue !== -1) {
                                localMarketAPIJson.marketData[itemHrid][enhanceLevel] = {
                                    a: askValue,
                                    b: bidValue
                                };
                            }
                        } else if (enhanceLevel === 0) {
                            // 左右都没有,强化等级为+0,记录为-1
                            localMarketAPIJson.marketData[itemHrid][enhanceLevel] = {
                                a: -1,
                                b: -1
                            }
                        } else {
                            // 左右都没有,强化等级不为+0,删除记录
                            delete localMarketAPIJson.marketData[itemHrid][enhanceLevel];
                        }
                    }
                    // 将修改后结果写回marketAPI缓存,完成对marketAPI价格的强制修改
                    localStorage.setItem("MWITools_marketAPI_json", JSON.stringify(localMarketAPIJson));
                }
            }

            return {ws: ws};
        }

        showTaskValue() {
            let taskValueObject;

            function getTaskTokenValue() {
                const chestDropData = JSON.parse(localStorage.getItem("Edible_Tools")).Chest_Drop_Data;
                const lootsName = ["大陨石舱", "大工匠匣", "大宝箱"];
                const bidValueList = [
                    parseFloat(chestDropData["Large Meteorite Cache"]["期望产出" + "Bid"]),
                    parseFloat(chestDropData["Large Artisan's Crate"]["期望产出" + "Bid"]),
                    parseFloat(chestDropData["Large Treasure Chest"]["期望产出" + "Bid"]),
                ];
                const askValueList = [
                    parseFloat(chestDropData["Large Meteorite Cache"]["期望产出" + "Ask"]),
                    parseFloat(chestDropData["Large Artisan's Crate"]["期望产出" + "Ask"]),
                    parseFloat(chestDropData["Large Treasure Chest"]["期望产出" + "Ask"]),
                ];
                const res = {
                    bidValue: Math.max(...bidValueList),
                    askValue: Math.max(...askValueList)
                };
                // bid和ask的最佳兑换选项
                res.bidLoots = lootsName[bidValueList.indexOf(res.bidValue)];
                res.askLoots = lootsName[askValueList.indexOf(res.askValue)];
                // bid和ask的任务代币价值
                res.bidValue = Math.round(res.bidValue / 30);
                res.askValue = Math.round(res.askValue / 30);
                // 小紫牛的礼物的额外价值计算
                res.giftValueBid = Math.round(parseFloat(chestDropData["Purple's Gift"]["期望产出" + "Bid"]));
                res.giftValueAsk = Math.round(parseFloat(chestDropData["Purple's Gift"]["期望产出" + "Ask"]));

                res.rewardValueBid = res.bidValue + res.giftValueBid / 50;
                res.rewardValueAsk = res.askValue + res.giftValueAsk / 50;
                return res;
            }

            function updateTaskValueNode(node) {
                const taskListNode = node.querySelector(".TasksPanel_taskList__2xh4k");
                if (!taskListNode) return;
                if (taskListNode.querySelector(".RangedWayIdleTaskValue")) return;

                for (const taskNode of taskListNode.querySelectorAll(".RandomTask_taskInfo__1uasf")) {
                    const rewardsNode = taskNode.querySelector(".RandomTask_rewards__YZk7D");
                    let coinCount = 0;
                    let taskTokenCount = 0;
                    for (const itemContainerNode of rewardsNode.querySelectorAll(".Item_itemContainer__x7kH1")) {
                        if (itemContainerNode.querySelector("use").href.baseVal.includes("coin")) {
                            coinCount = parseItemCount(itemContainerNode.querySelector(".Item_count__1HVvv").textContent);
                        } else if (itemContainerNode.querySelector("use").href.baseVal.includes("task_token")) {
                            taskTokenCount = parseItemCount(itemContainerNode.querySelector(".Item_count__1HVvv").textContent);
                        }
                    }

                    const askValue = taskTokenCount * taskValueObject.rewardValueAsk + coinCount;
                    const bidValue = taskTokenCount * taskValueObject.rewardValueBid + coinCount;

                    const taskValueDivNode = document.createElement("div");
                    taskValueDivNode.classList.add("RangedWayIdleTaskValue");
                    taskValueDivNode.textContent = I18N("taskExpectedValueText") + `${formatItemCount(askValue)} / ${formatItemCount(bidValue)}`;
                    taskValueDivNode.style.color = "#66CCFF";
                    taskValueDivNode.style.fontSize = "0.75rem";
                    taskNode.querySelector(".RandomTask_action__3eC6o").appendChild(taskValueDivNode);
                }
            }

            function updateTaskShopItemValue(node) {
                const taskShopPanelNode = node.querySelector(".TasksPanel_taskShop__q5sHL");
                if (!taskShopPanelNode) return;
                if (taskShopPanelNode.classList.contains("RangedWayIdleTaskShopValueSet")) return;
                const chestDropData = JSON.parse(localStorage.getItem("Edible_Tools")).Chest_Drop_Data;
                taskShopPanelNode.classList.add("RangedWayIdleTaskShopValueSet");
                const nameMap = {
                    "large_meteorite_cache": "Large Meteorite Cache",
                    "large_artisans_crate": "Large Artisan's Crate",
                    "large_treasure_chest": "Large Treasure Chest"
                }
                for (const taskShopItemNode of taskShopPanelNode.querySelectorAll(".TasksPanel_item__DWSpv")) {
                    const item = taskShopItemNode.querySelector(".TasksPanel_iconContainer__2JGVN use").href.baseVal.split("#")[1];
                    if (!Object.keys(nameMap).includes(item)) {
                        continue;
                    }
                    const name = nameMap[item];
                    const askValue = parseFloat(chestDropData[name]["期望产出" + "Ask"]);
                    const bidValue = parseFloat(chestDropData[name]["期望产出" + "Bid"]);
                    const divNode = document.createElement("div");
                    divNode.textContent = `${formatItemCount(askValue)} / ${formatItemCount(bidValue)}`;
                    divNode.style.color = "#66CCFF";
                    taskShopItemNode.insertBefore(divNode, taskShopItemNode.lastChild);
                }
            }

            function ws(obj) {
                if (obj.type === "quests_updated") {
                    // remove old task value nodes
                    document.querySelectorAll(".RangedWayIdleTaskValue").forEach(node => {
                        node.remove();
                    });
                }
            }

            function ob(node) {
                // set task expected value
                updateTaskValueNode(node);

                // set task shop item value
                updateTaskShopItemValue(node);
            }

            function init() {
                taskValueObject = getTaskTokenValue();
                if (configs.updateLocalStorageMarketPrice.value) {
                    const localMarketAPIJson = JSON.parse(localStorage.getItem("MWITools_marketAPI_json"));
                    localMarketAPIJson.marketData["/items/task_token"] = {
                        "0": {
                            a: taskValueObject.askValue,
                            b: taskValueObject.bidValue
                        }
                    };
                    localStorage.setItem("MWITools_marketAPI_json", JSON.stringify(localMarketAPIJson));
                }
            }

            return {ws: ws, ob: ob, init: init};
        }

        trackLeaderBoardData() {
            function getCurrentKey() {
                const selectedTabs = document.querySelectorAll(".LeaderboardPanel_tabsComponentContainer__mIgnw .Mui-selected");
                if (selectedTabs.length === 0) return;
                const selectedText = Array.from(selectedTabs).map((tab) => tab.textContent);
                return selectedText.join("-");
            }

            function createNoteAndButton(noteNode) {
                const keyString = getCurrentKey();

                // store data button
                const storeButton = document.createElement("button");
                storeButton.textContent = I18N("trackLeaderBoardDataLeaderboardStoreButton");
                storeButton.style.backgroundColor = "#66CCFF";
                storeButton.addEventListener("click", function () {
                    // get data
                    const leaderBoardData = {};
                    const tableNode = document.querySelector(".LeaderboardPanel_leaderboardTable__3JLvu");
                    for (const row of tableNode.querySelectorAll("tbody tr")) {
                        const characterNameNode = row.querySelector(".LeaderboardPanel_name__3hpvo").querySelector("span");
                        const guildNameNode = row.querySelector(".LeaderboardPanel_guildName__2RYcC");
                        const name = characterNameNode ? characterNameNode.textContent : guildNameNode.textContent;
                        const valueNode1 = row.querySelector(".LeaderboardPanel_valueColumn1__2HFDb");
                        const valueNode2 = row.querySelector(".LeaderboardPanel_valueColumn2__1ejF2");
                        const value = Number((valueNode2 ? valueNode2.textContent : valueNode1.textContent).replaceAll(",", ""));
                        leaderBoardData[name] = value || 0;
                    }

                    // store data
                    const localData = JSON.parse(localStorage.getItem("ranged_way_idle_leaderboard_data") || "{}");
                    localData[keyString] = {
                        data: leaderBoardData,
                        timestamp: new Date().getTime()
                    };
                    localStorage.setItem("ranged_way_idle_leaderboard_data", JSON.stringify(localData));
                });
                noteNode.appendChild(storeButton);

                // delete data button
                const deleteDataButton = document.createElement("button");
                deleteDataButton.textContent = I18N("trackLeaderBoardDataLeaderboardDeleteButton");
                deleteDataButton.style.backgroundColor = "#F44444";
                deleteDataButton.addEventListener("click", function () {
                    const localData = JSON.parse(localStorage.getItem("ranged_way_idle_leaderboard_data") || "{}");
                    delete localData[keyString];
                    localStorage.setItem("ranged_way_idle_leaderboard_data", JSON.stringify(localData));
                });
                noteNode.appendChild(deleteDataButton);

                // record time text node
                const localData = JSON.parse(localStorage.getItem("ranged_way_idle_leaderboard_data") || "{}");
                const recordTimeTextNode = document.createElement("div");
                if (localData[keyString]) {
                    const recordTime = new Date(localData[keyString].timestamp);
                    const timeDelta = (new Date().getTime() - localData[keyString].timestamp) / 3600000;
                    recordTimeTextNode.textContent = I18N("trackLeaderBoardDataLeaderboardRecordTimeText", {
                        recordTime: recordTime.toLocaleString(),
                        timeDelta: timeDelta.toFixed(2)
                    });
                } else {
                    recordTimeTextNode.textContent = I18N("trackLeaderBoardDataLeaderboardNoRecordTimeText");
                }
                noteNode.appendChild(recordTimeTextNode);

                // hint text node
                const noteTextNode = document.createElement("div");
                noteTextNode.textContent = I18N("trackLeaderBoardDataNoteText");
                noteNode.appendChild(noteTextNode);
            }

            function showDifference(leaderBoardContentNode) {
                const keyString = getCurrentKey();

                const allStoreData = JSON.parse(localStorage.getItem("ranged_way_idle_leaderboard_data") || "{}");
                if (!allStoreData || !allStoreData[keyString]) {
                    return;
                }
                // expand panel
                leaderBoardContentNode.style.maxWidth = '60rem';

                // get current data
                const localData = allStoreData[keyString].data;
                const timeDelta = (new Date().getTime() - allStoreData[keyString].timestamp) / 1000;
                const hourDelta = timeDelta / 3600;

                const tableNode = leaderBoardContentNode.querySelector(".LeaderboardPanel_leaderboardTable__3JLvu");

                // head
                const headNode = tableNode.querySelector("thead").firstChild;
                const diffNode = document.createElement("th");
                diffNode.textContent = I18N("trackLeaderBoardDataDifference");
                headNode.appendChild(diffNode);
                const speedNode = document.createElement("th");
                speedNode.textContent = I18N("trackLeaderBoardDataSpeed");
                headNode.appendChild(speedNode);
                const catchupTimeNode = document.createElement("th");
                catchupTimeNode.textContent = I18N("trackLeaderBoardDataCatchupTime");
                headNode.appendChild(catchupTimeNode);

                // body
                let previousRowValue = null;
                let previousRowSpeed = null;
                let maxSpeedValue = 0.0;
                let personalRow = null;
                let personalName = null;

                // calculate max speed for set color
                for (const row of tableNode.querySelectorAll("tbody tr")) {
                    const characterNameNode = row.querySelector(".LeaderboardPanel_name__3hpvo").querySelector("span");
                    const guildNameNode = row.querySelector(".LeaderboardPanel_guildName__2RYcC");
                    const name = characterNameNode ? characterNameNode.textContent : guildNameNode.textContent;
                    const valueNode1 = row.querySelector(".LeaderboardPanel_valueColumn1__2HFDb");
                    const valueNode2 = row.querySelector(".LeaderboardPanel_valueColumn2__1ejF2");
                    const value = Number((valueNode2 ? valueNode2.textContent : valueNode1.textContent).replaceAll(",", ""));
                    if (localData[name]) {
                        const diffValue = value - localData[name];
                        maxSpeedValue = Math.max(maxSpeedValue, diffValue / hourDelta);
                    }
                    if (row.classList.contains("LeaderboardPanel_personal__DZ7Nr")) {
                        personalRow = row;
                        personalName = name;
                    }
                }

                for (const row of tableNode.querySelectorAll("tbody tr")) {
                    const characterNameNode = row.querySelector(".LeaderboardPanel_name__3hpvo").querySelector("span");
                    const guildNameNode = row.querySelector(".LeaderboardPanel_guildName__2RYcC");
                    const name = characterNameNode ? characterNameNode.textContent : guildNameNode.textContent;
                    const valueNode1 = row.querySelector(".LeaderboardPanel_valueColumn1__2HFDb");
                    const valueNode2 = row.querySelector(".LeaderboardPanel_valueColumn2__1ejF2");
                    const value = Number((valueNode2 ? valueNode2.textContent : valueNode1.textContent).replaceAll(",", ""));

                    const diffValueNode = document.createElement("td");
                    diffValueNode.classList.add("RangedWayIdleLeaderBoardDiffValue");
                    const speedValueNode = document.createElement("td");
                    speedValueNode.classList.add("RangedWayIdleLeaderBoardSpeedValue");
                    const catchupTimeValueNode = document.createElement("td");
                    catchupTimeValueNode.classList.add("RangedWayIdleLeaderBoardCatchupTimeValue");

                    if (localData[name]) {
                        const diffValue = value - localData[name];
                        diffValueNode.textContent = diffValue.toLocaleString();
                        const speedValue = diffValue / hourDelta;
                        speedValueNode.textContent = formatItemCount(speedValue, 2) + "/h";

                        const k1 = Math.log(1 + (Math.E - 1) * speedValue / maxSpeedValue);
                        diffValueNode.style.color = `rgb(${255 - k1 * 255}, ${k1 * 255}, 0)`;
                        speedValueNode.style.color = `rgb(${255 - k1 * 255}, ${k1 * 255}, 0)`;

                        if (previousRowValue === null || previousRowSpeed === null) {
                            catchupTimeValueNode.textContent = "?????";
                            catchupTimeValueNode.style.color = "#66CCFF";
                        } else {
                            const deltaSpeed = speedValue - previousRowSpeed;
                            if (deltaSpeed === 0) {
                                if (previousRowValue === value) {
                                    catchupTimeValueNode.textContent = I18N("trackLeaderBoardDataCatchupTimeNow");
                                    catchupTimeValueNode.style.color = "#00FF00";
                                } else {
                                    catchupTimeValueNode.textContent = "∞";
                                    catchupTimeValueNode.style.color = "#FF0000";
                                }
                            } else {
                                const catchupTimeValue = (previousRowValue - value) / deltaSpeed;
                                if (catchupTimeValue > 0) {
                                    catchupTimeValueNode.textContent = formatItemCount(catchupTimeValue, 2) + "h";
                                    const k2 = 10000 / (10000 + catchupTimeValue * catchupTimeValue);
                                    catchupTimeValueNode.style.color = `rgb(${255 - k2 * 255}, ${k2 * 255}, 0)`;
                                } else if (catchupTimeValue === 0) {
                                    catchupTimeValueNode.textContent = "?????";
                                    catchupTimeValueNode.style.color = "#66CCFF";
                                } else {
                                    catchupTimeValueNode.textContent = "∞";
                                    catchupTimeValueNode.style.color = "#FF0000";
                                }
                            }
                        }
                        previousRowSpeed = speedValue;
                    } else {
                        diffValueNode.textContent = I18N("trackLeaderBoardDataNewRecordText");
                        speedValueNode.textContent = I18N("trackLeaderBoardDataNewRecordText");
                        catchupTimeValueNode.textContent = I18N("trackLeaderBoardDataNewRecordText");
                        diffValueNode.style.color = "#66CCFF";
                        speedValueNode.style.color = "#66CCFF";
                        catchupTimeValueNode.style.color = "#66CCFF";
                        previousRowSpeed = null;
                    }
                    previousRowValue = value;

                    // personal row
                    if (row.classList.contains("LeaderboardPanel_personal__DZ7Nr")) {
                        previousRowValue = null;
                        previousRowSpeed = null;
                    }

                    row.appendChild(diffValueNode);
                    row.appendChild(speedValueNode);
                    row.appendChild(catchupTimeValueNode);

                    if (personalRow && personalName === name) {
                        personalRow.querySelector(".RangedWayIdleLeaderBoardCatchupTimeValue").textContent = catchupTimeValueNode.textContent;
                        personalRow.querySelector(".RangedWayIdleLeaderBoardCatchupTimeValue").style.color = catchupTimeValueNode.style.color;
                    }
                }
            }

            function ob(node) {
                const leaderBoardRootNode = node.querySelector(".LeaderboardPanel_leaderboardPanel__19U0W");
                if (!leaderBoardRootNode) return;
                const noteNode = leaderBoardRootNode.querySelector(".LeaderboardPanel_note__z4OpJ");
                if (!noteNode) return;

                // make note and buttons
                if (noteNode.classList.contains("RangedWayIdleLeaderBoardNote")) return;
                noteNode.classList.add("RangedWayIdleLeaderBoardNote");
                createNoteAndButton(noteNode);

                // show difference
                const leaderBoardContentNode = leaderBoardRootNode.querySelector(".LeaderboardPanel_content__p_WNw");
                showDifference(leaderBoardContentNode);
            }

            return {ob: ob};
        }

        autoClickTaskSortButton() {
            function ob(node) {
                const buttonNode = node.querySelector('#TaskSort');
                if (!buttonNode || buttonNode.classList.contains("RangedWayIdleAutoClicked")) return;
                buttonNode.click();
                buttonNode.classList.add("RangedWayIdleAutoClicked");
            }

            return {ob: ob};
        }

        showMarketAPIUpdateTime() {
            let lastTime = 0;

            function ob(node) {
                const buttonContainerNode = node.querySelector(".MarketplacePanel_buttonContainer__vJQud");
                if (!buttonContainerNode) return;
                const nowTime = JSON.parse(localStorage.getItem('MWITools_marketAPI_json')).timestamp;
                if (nowTime === lastTime) return;
                lastTime = nowTime;
                const divNode = document.createElement("div");
                divNode.textContent = I18N("showMarketAPIUpdateTimeText") + " " + new Date(nowTime * 1000).toLocaleString();
                divNode.style.color = "rgb(102,204,255)";
                divNode.classList.add("RangedWayIdleShowMarketAPIUpdateTime");
                buttonContainerNode.insertBefore(divNode, buttonContainerNode.lastChild);
            }

            return {ob: ob};
        }

        forceUpdateAPIButton() {
            function ob(node) {
                const listingContainerNode = node.querySelector(".MarketplacePanel_listingCount__3nVY_");
                if (!listingContainerNode || !listingContainerNode.querySelector("button")) return;
                if (listingContainerNode.querySelector(".RangedWayIdleForceUpdateAPIButton")) return;
                const buttonNode = listingContainerNode.querySelector("button").cloneNode(true);
                buttonNode.classList.add("RangedWayIdleForceUpdateAPIButton");
                buttonNode.textContent = I18N("forceUpdateAPIButtonText");
                buttonNode.addEventListener("click", async function () {
                    if (GM && GM.xmlHttpRequest) {
                        GM.xmlHttpRequest({
                            method: 'GET',
                            url: globalVariables.marketAPIUrl,
                            onload: function (response) {
                                const text = response.responseText;
                                localStorage.setItem("MWITools_marketAPI_json", text);
                                alert(I18N("forceUpdateAPIButtonTextSuccess") + new Date(JSON.parse(text).timestamp * 1000).toLocaleString());
                            },
                            onerror: function (err) {
                                alert(I18N("forceUpdateAPIButtonTextError"));
                                console.error(err);
                            },
                            ontimeout: function () {
                                alert(I18N("forceUpdateAPIButtonTextTimeout"));
                                console.error('timeout');
                            }
                        });
                    } else {
                        const resp = await fetch(globalVariable.marketURL);
                        const text = await resp.text();
                        localStorage.setItem("MWITools_marketAPI_json", text);
                        alert(I18N("forceUpdateAPIButtonTextSuccess") + new Date(JSON.parse(text).timestamp * 1000).toLocaleString());
                    }
                });
                listingContainerNode.appendChild(buttonNode);
            }

            return {ob: ob};
        }

        disableQueueUpgradeButton() {
            const disabledButtons = [];

            function ob(node) {
                const buttons = node.querySelectorAll("button");
                for (const button of buttons) {
                    if ((button.textContent === "Upgrade Queue Capacity" || button.textContent === "升级行动队列") && !button.disabled) {
                        button.disabled = true;
                        disabledButtons.push(button);
                    }
                }
                for (let i = disabledButtons.length - 1; i >= 0; i--) {
                    const button = disabledButtons[i];
                    if (!button.isConnected || (button.textContent !== "Upgrade Queue Capacity" && button.textContent !== "升级行动队列")) {
                        button.disabled = false;
                        disabledButtons.splice(i, 1);
                    }
                }
            }

            return {ob: ob};
        }

        disableActionQueueBar() {
            function ob(node) {
                const actionQueueBarNode = node.querySelector(".QueuedActions_queuedActionsEditMenu__3OoQH");
                if (!actionQueueBarNode) return;
                const buttonNode = node.querySelector(".QueuedActions_queuedActions__2xerL ");
                buttonNode.click();
            }

            return {ob: ob};
        }

        hookListingInfo() {
            function handleListing(listing) {
                if (listing.status === "/market_listing_status/cancelled" ||
                    (listing.status === "/market_listing_status/filled" && listing.unclaimedItemCount === 0 && listing.unclaimedCoinCount === 0)) {
                    delete globalVariables.allListings[listing.id];
                    return;
                }
                globalVariables.allListings[listing.id] = {
                    id: listing.id,
                    isSell: listing.isSell,
                    itemHrid: listing.itemHrid,
                    enhancementLevel: listing.enhancementLevel,
                    orderQuantity: listing.orderQuantity,
                    filledQuantity: listing.filledQuantity,
                    price: listing.price,
                    coinsAvailable: listing.coinsAvailable,
                    unclaimedItemCount: listing.unclaimedItemCount,
                    unclaimedCoinCount: listing.unclaimedCoinCount,
                    createdTimestamp: listing.createdTimestamp,
                }
            }

            function ws(obj) {
                if (obj.type === "init_character_data") {
                    for (const listing of obj.myMarketListings) {
                        handleListing(listing);
                    }
                } else if (obj.type === "market_listings_updated") {
                    for (const listing of obj.endMarketListings) {
                        handleListing(listing);
                    }
                }
            }

            return {ws: ws};
        }

        showTotalListingFunds() {
            function ws(obj) {
                if (obj.type === "market_listings_updated") {
                    document.querySelectorAll(".RangedWayIdleTotalListingFunds").forEach(node => {
                        node.remove();
                    });
                }
            }

            function ob(node) {
                const marketplacePanelNode = node.querySelector(".MarketplacePanel_marketplacePanel__21b7o");
                if (!marketplacePanelNode) return;
                if (marketplacePanelNode.querySelector(".RangedWayIdleTotalListingFunds")) return;

                let totalUnclaimedCoins = 0;
                let totalPrepaidCoins = 0;
                let totalSellResultCoins = 0;

                for (const listing of Object.values(globalVariables.allListings)) {
                    totalUnclaimedCoins += listing.unclaimedCoinCount;
                    totalPrepaidCoins += listing.coinsAvailable;
                    if (listing.isSell) {
                        const tax = listing.itemHrid === "/items/bag_of_10_cowbells" ? 0.82 : 0.98;
                        totalSellResultCoins += (listing.orderQuantity - listing.filledQuantity) * Math.floor(listing.price * tax)
                    }
                }

                const currentCoinNode = marketplacePanelNode.querySelector(".MarketplacePanel_coinStack__1l0UD");

                const totalUnclaimedCoinsNode = currentCoinNode.cloneNode(true);
                const totalPrepaidCoinsNode = currentCoinNode.cloneNode(true);
                const totalSellResultCoinsNode = currentCoinNode.cloneNode(true);

                totalUnclaimedCoinsNode.querySelector(".Item_count__1HVvv").textContent = formatItemCount(totalUnclaimedCoins, configs.showTotalListingFundsPrecise.value);
                totalPrepaidCoinsNode.querySelector(".Item_count__1HVvv").textContent = formatItemCount(totalPrepaidCoins, configs.showTotalListingFundsPrecise.value);
                totalSellResultCoinsNode.querySelector(".Item_count__1HVvv").textContent = formatItemCount(totalSellResultCoins, configs.showTotalListingFundsPrecise.value);

                totalUnclaimedCoinsNode.querySelector(".Item_name__2C42x").textContent = I18N("totalUnclaimedCoinsText");
                totalPrepaidCoinsNode.querySelector(".Item_name__2C42x").textContent = I18N("totalPrepaidCoinsText");
                totalSellResultCoinsNode.querySelector(".Item_name__2C42x").textContent = I18N("totalSellResultCoinsText");

                totalUnclaimedCoinsNode.querySelector(".Item_name__2C42x").style.color = "#66CCFF";
                totalPrepaidCoinsNode.querySelector(".Item_name__2C42x").style.color = "#66CCFF";
                totalSellResultCoinsNode.querySelector(".Item_name__2C42x").style.color = "#66CCFF";

                currentCoinNode.style.left = "0rem";
                currentCoinNode.style.top = "0rem";
                totalUnclaimedCoinsNode.style.left = "0rem";
                totalUnclaimedCoinsNode.style.top = "1.5rem";
                totalPrepaidCoinsNode.style.left = "8rem";
                totalPrepaidCoinsNode.style.top = "0rem";
                totalSellResultCoinsNode.style.left = "8rem";
                totalSellResultCoinsNode.style.top = "1.5rem";

                totalUnclaimedCoinsNode.classList.add("RangedWayIdleTotalListingFunds");
                totalPrepaidCoinsNode.classList.add("RangedWayIdleTotalListingFunds");
                totalSellResultCoinsNode.classList.add("RangedWayIdleTotalListingFunds");

                marketplacePanelNode.insertBefore(totalUnclaimedCoinsNode, currentCoinNode.nextSibling);
                marketplacePanelNode.insertBefore(totalPrepaidCoinsNode, currentCoinNode.nextSibling);
                marketplacePanelNode.insertBefore(totalSellResultCoinsNode, currentCoinNode.nextSibling);
            }

            return {ws: ws, ob: ob}
        }

        showListingInfo() {
            const allCreateTimeNodes = [];
            let intervalId = null;

            function formatUTCTime(date) {
                return I18N("showListingInfoCreateTimeAt") + " " + date.toLocaleString('en-US', {
                    month: '2-digit',
                    day: '2-digit',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: false
                }).replace(/\//g, '-').replace(',', '');
            }

            function formatLifespan(date) {
                const diffMs = new Date() - date;
                const seconds = Math.floor(diffMs / 1000);
                const minutes = Math.floor(seconds / 60);
                const hours = Math.floor(minutes / 60);
                return I18N("showListingInfoCreateTimeLifespan") + " " +
                    [hours, (minutes % 60).toString().padStart(2, '0'), (seconds % 60).toString().padStart(2, '0')].join(':');
            }

            function handleTableHead(trNode) {
                const topOrderPriceNode = document.createElement("th");
                topOrderPriceNode.classList.add("RangedWayIdleShowListingInfo");
                const totalPriceNode = document.createElement("th");
                totalPriceNode.classList.add("RangedWayIdleShowListingInfo");

                topOrderPriceNode.textContent = I18N("showListingInfoTopOrderPriceText");
                totalPriceNode.textContent = I18N("showListingInfoTotalPriceText");

                trNode.insertBefore(topOrderPriceNode, trNode.children[4]);
                trNode.insertBefore(totalPriceNode, trNode.children[5]);
            }

            function addDataToRows(bodyNode) {
                let index = Object.keys(globalVariables.allListings).length - 1;
                for (const listingId in globalVariables.allListings) {
                    const trNode = bodyNode.childNodes[index];
                    for (const key in globalVariables.allListings[listingId]) {
                        trNode.dataset[key] = globalVariables.allListings[listingId][key];
                    }
                    trNode.dataset.originalIndex = index;
                    index--;
                }
            }

            function handleTableBody(tbodyNode) {
                const localMarketAPIJson = JSON.parse(localStorage.getItem('MWITools_marketAPI_json'));
                for (const trNode of tbodyNode.querySelectorAll("tr")) {
                    const dataSet = trNode.dataset;

                    // top order price
                    const topOrderPriceNode = document.createElement("td");
                    topOrderPriceNode.classList.add("RangedWayIdleShowListingInfo");
                    const topOrderPriceSpanNode = document.createElement("span");
                    topOrderPriceSpanNode.classList.add("RangedWayIdleShowListingInfo");
                    const itemHrid = dataSet.itemHrid;
                    const enhancementLevel = Number(dataSet.enhancementLevel);
                    const isSell = dataSet.isSell === 'true';
                    const price = Number(dataSet.price);
                    let localPrice = null;
                    try {
                        localPrice = localMarketAPIJson.marketData[itemHrid][enhancementLevel][isSell ? "a" : "b"];
                    } catch (e) {
                    }
                    if (localPrice === -1) localPrice = null;
                    topOrderPriceSpanNode.textContent = formatItemCount(localPrice);
                    if (localPrice === null) {
                        topOrderPriceSpanNode.style.color = "#004FFF";
                    } else if (isSell) {
                        topOrderPriceSpanNode.style.color = localPrice < price ? "#FF0000" : "#00FF00";
                    } else {
                        topOrderPriceSpanNode.style.color = localPrice > price ? "#FF0000" : "#00FF00";
                    }
                    topOrderPriceNode.appendChild(topOrderPriceSpanNode);
                    trNode.insertBefore(topOrderPriceNode, trNode.children[4]);

                    // total price
                    const totalPriceNode = document.createElement("td");
                    totalPriceNode.classList.add("RangedWayIdleShowListingInfo");
                    const totalPriceSpanNode = document.createElement("span");
                    totalPriceSpanNode.classList.add("RangedWayIdleShowListingInfo");
                    const orderQuantity = Number(dataSet.orderQuantity);
                    const filledQuantity = Number(dataSet.filledQuantity);
                    const tax = isSell ? (itemHrid === "/items/bag_of_10_cowbells" ? 0.82 : 0.98) : 1.0;
                    const totalPrice = (orderQuantity - filledQuantity) * Math.floor(price * tax);
                    totalPriceSpanNode.textContent = formatItemCount(totalPrice, configs.showListingPricePrecise.value);
                    totalPriceSpanNode.style.color = itemCountColorMap(totalPrice);
                    totalPriceNode.appendChild(totalPriceSpanNode);
                    trNode.insertBefore(totalPriceNode, trNode.children[5]);

                    // add create time
                    const createTimeNode = document.createElement("div");
                    createTimeNode.classList.add("RangedWayIdleShowListingInfo");
                    createTimeNode.style.fontSize = '0.75rem';
                    if (configs.showListingCreateTimeByLifespan.value) {
                        createTimeNode.textContent = formatLifespan(new Date(dataSet.createdTimestamp));
                        allCreateTimeNodes.push(createTimeNode);
                    } else {
                        createTimeNode.textContent = formatUTCTime(new Date(dataSet.createdTimestamp));
                    }
                    createTimeNode.style.color = "gray";
                    trNode.firstChild.appendChild(createTimeNode);
                }
            }

            function updateLifespan() {
                if (!configs.showListingCreateTimeByLifespan.value) {
                    allCreateTimeNodes.length = 0;
                    if (intervalId !== null) {
                        resetAll();
                        clearInterval(intervalId);
                        intervalId = null;
                    }
                    return;
                }
                allCreateTimeNodes.forEach(node => {
                    if (!node.isConnected) {
                        allCreateTimeNodes.splice(allCreateTimeNodes.indexOf(node), 1);
                        node.remove();
                        return;
                    }
                    const newText = formatLifespan(new Date(node.parentNode.parentNode.dataset.createdTimestamp));
                    if (newText !== node.textContent) {
                        node.textContent = newText;
                    }
                });
                if (intervalId === null) {
                    resetAll();
                    intervalId = setInterval(updateLifespan, 250);
                }
            }

            function resetAll() {
                const myListingTableNode = document.querySelector(".MarketplacePanel_myListingsTable__3P1aT");
                const bodyNode = myListingTableNode.querySelector("tbody");
                const sortedChildren = Array.from(bodyNode.childNodes).sort((a, b) => parseInt(b.dataset.id) - parseInt(a.dataset.id));
                sortedChildren.forEach(node => bodyNode.appendChild(node));
                myListingTableNode.classList.remove("RangedWayIdleShowListingInfoSet");
                document.querySelectorAll(".RangedWayIdleShowListingInfo").forEach(node => {
                    node.remove();
                });
            }

            function ws(obj) {
                if (obj.type === "market_listings_updated") {
                    resetAll();
                }
            }

            function ob(node) {
                updateLifespan();
                const myListingTableNode = node.querySelector(".MarketplacePanel_myListingsTable__3P1aT");
                if (!myListingTableNode) return;
                if (myListingTableNode.classList.contains("RangedWayIdleShowListingInfoSet")) return;
                if (myListingTableNode.querySelectorAll("tbody tr").length !== Object.keys(globalVariables.allListings).length) {
                    // console.error("Listings length not match!");
                    return;
                }
                myListingTableNode.classList.add("RangedWayIdleShowListingInfoSet");

                handleTableHead(myListingTableNode.querySelector("thead tr"));
                addDataToRows(myListingTableNode.querySelector("tbody"));
                handleTableBody(myListingTableNode.querySelector("tbody"));
            }

            return {ws: ws, ob: ob};
        }

        notifyListingFilled() {
            function ws(obj) {
                if (obj.type === "market_listings_updated") {
                    for (const listing of obj.endMarketListings) {
                        if (listing.status === "/market_listing_status/filled" && (listing.unclaimedCoinCount || listing.unclaimedItemCount)) {
                            globalVariables.notifyListingFilledAudio.volume = configs.notifyListingFilledVolume.value;
                            globalVariables.notifyListingFilledAudio.play();
                            return;
                        }
                    }
                }
            }

            return {ws: ws};
        }

        estimateListingCreateTime() {
            let lastMarketItemOrderBooks = null;

            function formatUTCTime(date) {
                return date.toLocaleString('en-US', {
                    month: '2-digit',
                    day: '2-digit',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: false
                }).replace(/\//g, '-').replace(',', '');
            }

            function getListingData() {
                // author's data
                const data = [
                    {id: 97888637, timestamp: 1760266805648},
                    {id: 98545826, timestamp: 1760496508616},
                    {id: 98724734, timestamp: 1760551920380},
                    {id: 98978743, timestamp: 1760637750329}
                ];
                for (const listing of Object.values(globalVariables.allListings)) {
                    data.push({id: listing.id, timestamp: new Date(listing.createdTimestamp).getTime()});
                }
                return [...data].sort((a, b) => a.id - b.id);
            }

            function estimateCreateTime(sortedData, id) {
                const minId = sortedData[0].id;
                const maxId = sortedData[sortedData.length - 1].id;
                if (minId <= id && id <= maxId) {
                    return linearInterpolationEstimate();
                } else {
                    return linearRegressionEstimate();
                }

                function linearInterpolationEstimate() {
                    let leftIndex = 0;
                    let rightIndex = sortedData.length - 1;
                    for (let i = 0; i < sortedData.length; i++) {
                        if (sortedData[i].id === id) {
                            return sortedData[i].timestamp;
                        }
                    }
                    for (let i = 0; i < sortedData.length - 1; i++) {
                        if (id >= sortedData[i].id && id <= sortedData[i + 1].id) {
                            leftIndex = i;
                            rightIndex = i + 1;
                            break;
                        }
                    }
                    const left = sortedData[leftIndex];
                    const right = sortedData[rightIndex];
                    const rightLeftDistance = right.id - left.id;
                    const leftDistance = id - left.id;
                    const k = leftDistance / rightLeftDistance;
                    return (1 - k) * left.timestamp + k * right.timestamp;
                }

                function linearRegressionEstimate() {
                    let sumX = 0, sumY = 0;
                    for (const point of sortedData) {
                        sumX += point.id;
                        sumY += point.timestamp;
                    }
                    const meanX = sumX / sortedData.length;
                    const meanY = sumY / sortedData.length;
                    let numerator = 0;
                    let denominator = 0;
                    for (const datum of sortedData) {
                        numerator += (datum.id - meanX) * (datum.timestamp - meanY);
                        denominator += (datum.id - meanX) * (datum.timestamp - meanX);
                    }
                    const slope = numerator / denominator;
                    if (id > maxId) {
                        return slope * (id - maxId) + sortedData[sortedData.length - 1].timestamp;
                    } else {
                        return slope * (id - minId) + sortedData[0].timestamp;
                    }
                }
            }

            function colorByAccuracy(sortedData, timestamp) {
                const timeDelta = Math.min(...sortedData.map(item => Math.abs(item.timestamp - timestamp)));
                return Math.max(1 - timeDelta / 86400_000, 0.0);
            }

            function colorByLifespan(sortedData, timestamp) {
                const timeDelta = Math.max(new Date().getTime() - timestamp, 0);
                const meanTime = 172800_000;
                return (meanTime * meanTime) / (meanTime * meanTime + timeDelta * timeDelta);
            }

            function ws(obj) {
                if (obj.type === "market_item_order_books_updated") {
                    lastMarketItemOrderBooks = obj.marketItemOrderBooks;
                    document.querySelectorAll(".RangedWayIdleEstimateListingCreateTimeSet").forEach(node => node.classList.remove("RangedWayIdleEstimateListingCreateTimeSet"));

                }
            }

            function ob(node) {
                const targetItemNode = node.querySelector(".MarketplacePanel_currentItem__3ercC");
                if (!targetItemNode) return;
                if (node.querySelector(".RangedWayIdleEstimateListingCreateTimeSet")) return;
                document.querySelectorAll(".RangedWayIdleEstimateListingCreateTime").forEach(node => {
                    node.remove();
                });

                const itemHrid = "/items/" + targetItemNode.querySelector("use").href.baseVal.split('#')[1];
                const enhanceLevelNode = targetItemNode.querySelector(".Item_enhancementLevel__19g-e");
                const enhanceLevel = enhanceLevelNode ? Number(enhanceLevelNode.textContent.substring(1)) : 0;
                if (itemHrid !== lastMarketItemOrderBooks.itemHrid) return;

                const listingContainer = node.querySelector(".MarketplacePanel_orderBooksContainer__B4YE-");
                const askContainer = listingContainer ? listingContainer.childNodes[0] : node.querySelectorAll(".MarketplacePanel_orderBookTableContainer__hUu-X")[0];
                const bidContainer = listingContainer ? listingContainer.childNodes[1] : node.querySelectorAll(".MarketplacePanel_orderBookTableContainer__hUu-X")[1];
                if (!askContainer || !bidContainer) return;
                askContainer.classList.add("RangedWayIdleEstimateListingCreateTimeSet");
                bidContainer.classList.add("RangedWayIdleEstimateListingCreateTimeSet");
                if (!askContainer || !bidContainer) return;
                const askTable = askContainer.querySelector("table");
                const bidTable = bidContainer.querySelector("table");
                if (!askTable || !bidTable) return;
                if (askTable.querySelector("tbody").childNodes.length !== lastMarketItemOrderBooks.orderBooks[enhanceLevel].asks.length ||
                    bidTable.querySelector("tbody").childNodes.length !== lastMarketItemOrderBooks.orderBooks[enhanceLevel].bids.length) {
                    return;
                }

                // head
                const askTimeHead = document.createElement("th");
                askTimeHead.classList.add("RangedWayIdleEstimateListingCreateTime");
                const bidTimeHead = document.createElement("th");
                bidTimeHead.classList.add("RangedWayIdleEstimateListingCreateTime");
                askTimeHead.textContent = I18N("estimateListingCreateTimeText");
                bidTimeHead.textContent = I18N("estimateListingCreateTimeText");
                askTable.querySelector("thead tr").insertBefore(askTimeHead, askTable.querySelector("thead tr").lastChild);
                bidTable.querySelector("thead tr").insertBefore(bidTimeHead, bidTable.querySelector("thead tr").lastChild);


                // body
                const sortedData = getListingData();
                let askIndex = 0, bidIndex = 0;
                for (const row of askTable.querySelectorAll("tbody tr")) {
                    const listingId = lastMarketItemOrderBooks.orderBooks[enhanceLevel].asks[askIndex].listingId;
                    const estimatedTime = estimateCreateTime(sortedData, listingId);
                    const node = document.createElement("td");
                    node.classList.add("RangedWayIdleEstimateListingCreateTime");
                    node.textContent = formatUTCTime(new Date(estimatedTime));
                    if (configs.estimateListingCreateTimeColorByAccuracy.value) {
                        const k = colorByAccuracy(sortedData, estimatedTime);
                        node.style.color = `rgb(${255 - k * 255}, ${k * 255}, 0)`;
                    } else if (configs.estimateListingCreateTimeColorByLifespan.value) {
                        const k = colorByLifespan(sortedData, estimatedTime);
                        node.style.color = `rgb(${255 - k * 255}, ${k * 255}, 0)`;
                    }
                    row.insertBefore(node, row.lastChild);
                    askIndex++;
                }
                for (const row of bidTable.querySelectorAll("tbody tr")) {
                    const listingId = lastMarketItemOrderBooks.orderBooks[enhanceLevel].bids[bidIndex].listingId;
                    const estimatedTime = estimateCreateTime(sortedData, listingId)
                    const node = document.createElement("td");
                    node.classList.add("RangedWayIdleEstimateListingCreateTime");
                    node.textContent = formatUTCTime(new Date(estimatedTime));
                    if (configs.estimateListingCreateTimeColorByAccuracy.value) {
                        const k = colorByAccuracy(sortedData, estimatedTime);
                        node.style.color = `rgb(${255 - k * 255}, ${k * 255}, 0)`;
                    } else if (configs.estimateListingCreateTimeColorByLifespan.value) {
                        const k = colorByLifespan(sortedData, estimatedTime);
                        node.style.color = `rgb(${255 - k * 255}, ${k * 255}, 0)`;
                    }
                    row.insertBefore(node, row.lastChild);
                    bidIndex++;
                }

            }

            return {ws: ws, ob: ob};
        }

        mournForMagicWayIdle() {
            function init() {
                console.log("为法师助手默哀");
            }

            return {init: init};
        }
    }

    function I18N(key, data) {
        const defaultLanguage = "zh-cn";
        let i18nValue;
        if (!I18NMap[key]) {
            i18nValue = key;
        } else if (I18NMap[key][globalVariables.language]) {
            i18nValue = I18NMap[key][globalVariables.language];
        } else if (I18NMap[key][defaultLanguage]) {
            i18nValue = I18NMap[key][defaultLanguage];
        } else {
            i18nValue = key;
        }
        return fillTemplate(i18nValue, data || {});

        function fillTemplate(template, data) {
            return template.replace(/\$\{(\w+)\}/g, (match, key) => {
                return data[key] !== undefined ? data[key] : match;
            });
        }
    }

    function formatItemCount(num, precise = 0) {
        if (num === null) return "NULL";
        num = Number(num);
        if (isNaN(num)) {
            return "NULL";
        }
        const divisorMap = [
            {threshold: 1e13, divisor: 1e12, unit: "T"},
            {threshold: 1e10, divisor: 1e9, unit: "B"},
            {threshold: 1e7, divisor: 1e6, unit: "M"},
            {threshold: 1e4, divisor: 1e3, unit: "K"}
        ];
        for (const {threshold, divisor, unit} of divisorMap) {
            if (Math.abs(num) >= threshold) {
                const value = Math.floor(num / divisor * Math.pow(10, precise)) / Math.pow(10, precise);
                return value + unit;
            }
        }
        return Math.floor(num * Math.pow(10, precise)) / Math.pow(10, precise);
    }

    function parseItemCount(str) {
        const unitMap = {
            "T": 1e12,
            "B": 1e9,
            "M": 1e6,
            "K": 1e3
        }
        for (const unit in unitMap) {
            if (str.endsWith(unit)) {
                const value = Number(str.slice(0, -1));
                return value * unitMap[unit];
            }
        }
        return Number(str);
    }

    function itemCountColorMap(num) {
        if (Math.abs(num) < 1e5) {
            return "#FFFFFF";
        }
        if (Math.abs(num) < 1e7) {
            return "#FDDAA5";
        }
        if (Math.abs(num) < 1e10) {
            return "#82DCCA";
        }
        if (Math.abs(num) < 1e13) {
            return "#77BAEC";
        }
        if (Math.abs(num) < 1e16) {
            return "#AC8FD4";
        }
        return "#F800F8";
    }

    initScript();
})();