XP/h

XP/h tracker for MWI

// ==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");

})();