// ==UserScript==
// @name XP/h
// @namespace http://tampermonkey.net/
// @version 2025-04-07
// @description XP/h tracker for MWI
// @license MIT
// @author sentientmilk
// @match https://www.milkywayidle.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=milkywayidle.com
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_deleteValues
// @run-at document-start
// ==/UserScript==
(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: "power", hrid: "/skills/power", name: "Power" },
{ 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");
})();