您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
XP/h tracker for MWI
// ==UserScript== // @name XP/h // @namespace http://tampermonkey.net/ // @version 2025-08-19 // @description XP/h tracker for MWI // @license MIT // @author sentientmilk // @match https://www.milkywayidle.com/* // @icon https://www.milkywayidle.com/favicon.svg // @grant unsafeWindow // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @grant GM_deleteValues // @run-at document-start // ==/UserScript== /* Changelog ========= v2025-04-06 - Initial version v2025-04-07 - FIXED: Compatibility with Meme Script v2025-08-19 - FIXED: Skill changes after the 19.08.2025 Combat rework TODO ==================== - Conflicts with https://greasyfork.org/en/scripts/499963-银河奶牛-食用工具 sidebar buttons are gone, also dispatch and loot button - Conflict with Edible tools, missing sidebar buttons - Conflict with Edible tools, missing book lvl up in books Items description - Conflict with Edible tools, missing buttons in the Party screen */ (function() { function reset () { const keys = GM_listValues(); GM_deleteValues(keys); console.log("XP/h: Deleted stored values for", keys); } unsafeWindow.xpUserscriptReset = reset; async function waitFor (selector) { return new Promise((resolve) => { function check () { const el = document.querySelector(selector); if (el) { resolve(el); } else { setTimeout(check, 1000/30); } } check(); }); } function fs (n) { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); } function f (n) { if (typeof n != "number") { return "NaN"; } else if (n == 0) { return n; } else if (Math.abs(n) < 1) { return n.toFixed(2); } else if (Math.abs(n) < 10*1000) { if (n % 1 == 0) { return "" + n; } else { return n.toFixed(1); } } else if (Math.abs(n) <= 1*1000*1000) { const k = n/1000; if (k % 1 == 0) { return k + "K"; } else { return k.toFixed(1) + "K"; } } else if (Math.abs(n) > 1*1000*1000) { const m = n/(1000*1000); if (m % 1 == 0) { return m + "M"; } else if (m % 0.1 == 0) { return m.toFixed(1) + "M"; } else { return m.toFixed(2) + "M"; } } else { return "" + n; } } /* ============================================ Original is in Guild XP/h - copy from there! ============================================ */ let m10 = 10 * 60 * 1000; let h1 = 60 * 60 * 1000; let w1 = 7 * 24 * 60 * 60 * 1000; function pushXP (arr, d, recent=m10, far=h1, old=w1) { // Debug: Delete duplicate XPs /* for (let i = arr.length - 1; i >= 0; i--) { const d = arr[i]; const same = arr.filter((d2) => d2 != d && d2.xp == d.xp); same.reverse().forEach((d2) => { const i2 = arr.indexOf(d2); arr.splice(i2, 1); i--; }); } */ // Debug: Delete values not in order /* for (let i = 0; i < arr.length; i++) { const d = arr[i]; if (i > 0) { const prev = arr[i-1]; if (d.xp < prev.xp) { arr.splice(i, 1); i--; } } } */ if (arr.length == 0 || d.xp >= arr[arr.length - 1].xp) { arr.push(d); } else { // Why can it happen??? console.error("Guild XP/h: Received lower XP value"); } if (arr.length > 2) { // Assume records are in order let recentLength = 0; for (let i = arr.length - 1; i >= 0; i--) { const d2 = arr[i]; if (d.t - d2.t <= recent) { recentLength += 1 } else { break; } } if (recentLength > 2) { // Keep a first and last recond in *recent* time // To always have the latest data // But without adding too many records with short time between // If I keep only the last - it will always replace if you check more often then *recently* arr.splice(arr.length - recentLength + 1, recentLength - 2); } let sameLength = 0; for (let i = arr.length - 1; i >= 0; i--) { const d2 = arr[i]; // Keep same XP values if they are far apart if (d.xp == d2.xp && d.t - d2.t <= far) { sameLength += 1 } else { break; } } if (sameLength > 1) { // Keep only the last recond with the same XP value arr.splice(arr.length - sameLength, sameLength - 1); } let oldLength = 0; for (let i = 0; i < arr.length; i++) { const d2 = arr[i]; if (d.t - d2.t > old) { oldLength += 1; } } if (oldLength > 0 ) { arr.splice(0, oldLength); } } } function inLastInterval (arr, interval) { let filtered = []; const now = Date.now(); for (let i = arr.length - 1; i >= 0; i--) { const d = arr[i]; if (now - d.t <= interval) { filtered.unshift(d); } else { // Skip } } return filtered; } function calcXPH (prev, d) { const xpD = d.xp - prev.xp; const tD = d.t - prev.t; const xpH = (xpD / (tD / (60 * 1000))) * 60; return xpH; } function calcIndividualStats2 (arr, options={}) { if (arr.length < 2) { return { lastXPH: 0, lastHourXPH: 0, }; } const m10 = 10 * 60 * 1000; const lastArr = inLastInterval(arr, m10); const lastXPH = lastArr.length >= 2 ? calcXPH(lastArr[0], lastArr[lastArr.length - 1]) : 0; const h1 = 60 * 60 * 1000; const lastHourArr = inLastInterval(arr, h1); const lastHourXPH = lastHourArr.length >= 2 ? calcXPH(lastHourArr[0], lastHourArr[lastHourArr.length - 1]) : 0; return { lastXPH, lastHourXPH, }; } async function updateXPH () { await waitFor(".NavigationBar_nav__3uuUl"); let idToEl = {}; const navEls = document.querySelectorAll(".NavigationBar_nav__3uuUl:has(.NavigationBar_currentExperience__3GDeX)"); navEls.forEach((navEl) => { const id = navEl.querySelector("svg use").getAttribute("href").split("#")[1]; const labelEl = navEl.querySelector(".NavigationBar_label__1uH-y"); idToEl[id] = labelEl; }); let characterXP = GM_getValue("characterXP_"+characterID, {}); skills.forEach(async (s, i) => { const stats = calcIndividualStats2(characterXP[s.id]); if (s.name == "Total Level") { document.querySelector(".Header_rightHeader__8LPWK").style.maxWidth = "350px"; const template = ` <div class="xph-userscript" style="font-size: 13px; color: orange;"> <span style="font-weight: 500;">${f(stats.lastXPH)} xp/h</span> <span>(${f(stats.lastHourXPH)} xp in the last hour)</span> </div> `; const totalEl = await waitFor(".Header_totalLevel__8LY3Q"); totalEl.parentElement.querySelector(".xph-userscript")?.remove(); if (stats.lastHourXPH > 0) { totalEl.insertAdjacentHTML("afterend", template); } } else { const template = `<span class="xph-userscript" style="font-size: 13px; color: orange;">${f(stats.lastXPH)} xp/h</span>`; const labelEl = idToEl[s.id]; labelEl.parentElement.querySelector(".xph-userscript")?.remove(); labelEl.parentElement.querySelector(".NavigationBar_level__3C7eR").style.width = "auto"; if (stats.lastXPH > 0) { labelEl.insertAdjacentHTML("afterend", template); } } }); } //const initClientData = JSON.parse(localStorage.getItem("initClientData")); const skills = [ { id: "total_level", hrid: "/skills/total_level", name: "Total Level" }, { id: "milking", hrid: "/skills/milking", name: "Milking" }, { id: "foraging", hrid: "/skills/foraging", name: "Foraging" }, { id: "woodcutting", hrid: "/skills/woodcutting", name: "Woodcutting" }, { id: "cheesesmithing", hrid: "/skills/cheesesmithing", name: "Cheesesmithing" }, { id: "crafting", hrid: "/skills/crafting", name: "Crafting" }, { id: "tailoring", hrid: "/skills/tailoring", name: "Tailoring" }, { id: "cooking", hrid: "/skills/cooking", name: "Cooking" }, { id: "brewing", hrid: "/skills/brewing", name: "Brewing" }, { id: "alchemy", hrid: "/skills/alchemy", name: "Alchemy" }, { id: "enhancing", hrid: "/skills/enhancing", name: "Enhancing" }, { id: "stamina", hrid: "/skills/stamina", name: "Stamina" }, { id: "intelligence", hrid: "/skills/intelligence", name: "Intelligence" }, { id: "attack", hrid: "/skills/attack", name: "Attack" }, { id: "melee", hrid: "/skills/melee", name: "Melee" }, { id: "defense", hrid: "/skills/defense", name: "Defense" }, { id: "ranged", hrid: "/skills/ranged", name: "Ranged" }, { id: "magic", hrid: "/skills/magic", name: "Magic" }, ]; const skillHridToName = {}; skills.forEach((s) => skillHridToName[s.hrid] = s.name); let characterID; function handle (message) { if (message.type == "init_character_data") { const t = +new Date(message.currentTimestamp); characterID = message.character.id; let characterXP = GM_getValue("characterXP_"+characterID, {}); skills.forEach((s) => { const e = message.characterSkills.find((e) => e.skillHrid == s.hrid); const xp = e.experience; if (!characterXP[s.id]) { characterXP[s.id] = []; } const d = { t, xp }; pushXP(characterXP[s.id], d); //console.log(s.name + ": " + fs(Math.floor(xp))); }); GM_setValue("characterXP_"+characterID, characterXP); updateXPH(); } else if (message.type == "action_completed") { const t = +new Date(message.endCharacterSkills[0].updatedAt); let characterXP = GM_getValue("characterXP_"+characterID, {}); skills.forEach((s) => { const e = message.endCharacterSkills.find((e) => e.skillHrid == s.hrid); if (e) { const xp = e.experience; if (!characterXP[s.id]) { characterXP[s.id] = []; } const d = { t, xp }; pushXP(characterXP[s.id], d); //console.log(s.name + ": " + fs(Math.floor(xp))); } }); GM_setValue("characterXP_"+characterID, characterXP); updateXPH(); } } const OriginalWebSocket = unsafeWindow.WebSocket; const WrappedWebSocket = function (...args) { const ws = new OriginalWebSocket(...args) ws.addEventListener("message", function (e) { const message = JSON.parse(e.data); handle(message); }) return ws; }; unsafeWindow.WebSocket = WrappedWebSocket; console.log("XP/h: Wrapped window.WebSocket"); console.log("XP/h: Call window.xpUserscriptReset(); - to reset saved XP"); })();