Guild XP/h

Guild XP/h tracker for MWI

// ==UserScript==
// @name         Guild XP/h
// @namespace    http://tampermonkey.net/
// @version      2025-08-19
// @description  Guild 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_getValues
// @grant        GM_setValues
// @grant        GM_listValues
// @grant        GM_deleteValues
// @run-at       document-start
// ==/UserScript==

/*
	Changelog
	=========

	v2025-04-04
		- Initial version
	v2025-04-04 v2
		- FIXED: if you check XP too often (10m) it'll replace data instead of adding
		- Sort by Rank (or Level, Experience)
		- Sort by "Last hour XP gain" (or "Last hour XP/h")
		- Sort by "Last day XP gain" (or "Last day XP/h")
	v2025-04-04 v3
		- FIXED: Horizontal legend on the Last week XP/h chart wasn't aligned properly
		- Separate "Last hour/day XP/h" and "Last hour/day XP #" columns
		- Everything is sortable on Guild -> Members tab
		- Everything is sortable on Leaderboard -> Guild, except "Name"
	v2025-04-04-v3
		- FIXED: Version field
	v2025-04-06
		- FIXED: Chart tooltip was off when zoomed in
		- Show "Last XP/h" on Guild -> Overview if not enough data for "Last hour XP/h"
		- Changed "Last hour XP/h" to "Last XP/h", same for ranks (on Guild -> Members and Leaderboard -> Guild)
	v2025-04-06-v2
		- Don't store data older than 1 week
	v2025-04-07
		- FIXED: "Last XP/h" (On Leaderboard -> Guild tab) didn't stick the first row
		- Added Export data to a file (On Settings -> Profile tab)
		- Added Import data from a file (On Settings -> Profile tab)
		- Added Delete all data (On Settings -> Profile tab)
	v2025-07-10
		- FIXED: Settings buttons misaligned
		- FIXED: Settings onclick was conflicting with another mod
		- FIXED: "undefined" in XPs
		- Added time to level up
		- Merged Last XP/h with # column + on the guild leaderboard
		- Merged Last day XP/h with # column + on the guild leaderboard
		- Added emojis for 1st, 2nd, 3rd place
		- Made default sorting direction desc
		- Added sorting idle activities, like "23d ago" or empty
		- Removed Max XP/h from the guild leaderboard
		- Changed Status sorting
		- When joined
	v2025-08-19
		- Truncate anomalously hight XP/h values on the chart (for the 19.08.2025 combat rework XP redistribution)
*/

/*
			TODO
	====================
	- Conflicts with MWITools, "Current Assets" not showing
	- Conflicts with MWITools, sort items by, Character Build Score and the Total NetWorth not showing
	- Conflicts when run under ViolentMonkey on PC and Android

	- Combat Level
	- Who has the top lvl in a skill
	- Icons for setups
	- Use Lurpas server?
	- How many ppl doing what skill
	- Possible conflict with MWITools
*/

(function() {
	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 f (n) {
		return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
	}

	function reset () {
		const ok = confirm("Are you sure you want to delete ALL Guild XH/h data?");
		if (ok) {
			const keys = GM_listValues();
			GM_deleteValues(keys);
			console.log("Guild XP/h: Deleted stored values for", keys);
		}
	}

	function downloadFile (fileName, data) {
		const json = JSON.stringify(data, null, "\t");
		const blob = new Blob([json], { type: "octet/stream" });
		const url = URL.createObjectURL(blob);

		let a = document.createElement("a");
		document.body.appendChild(a);
		a.style.display = "none";
		a.href = url;
		a.download = fileName;
		a.click();
		a.remove();

		URL.revokeObjectURL(url);
	}

	async function uploadFile () {
		return new Promise((resolve) => {
			var input = document.createElement("input");
			//input.style.display = "none";
			document.body.appendChild(input);
			input.type = "file";

			input.onchange = (event) => {
				const file = event.target.files[0];

				const reader = new FileReader();
				reader.readAsText(file, "UTF-8");

				reader.onload = (event2) => {
					let text = event2.target.result;
					let data = JSON.parse(text);
					input.remove();
					resolve(data);
				}
			}

			input.click();
		});
	}

	function exportData () {
		let data = GM_getValues(GM_listValues());
		downloadFile("guildsXPh-"+characterName+".json", data);
	}

	async function importData () {
		const data = await uploadFile();

		const keys = GM_listValues();
		GM_deleteValues(keys);

		GM_setValues(data);
	}

	unsafeWindow.guildXPUserscriptDebug = false;

	unsafeWindow.guildXPUserscriptReset = reset;
	unsafeWindow.guildXPExportData = exportData;
	unsafeWindow.guildXPImportData = importData;

	function debugValue (id, value) {
		if (unsafeWindow.guildXPUserscriptDebug) {
			unsafeWindow[id] = value;
			console.log("window." + id + "=");
			console.log(value);
		}
	}

	function cleanData () {
		console.log("cleanData");
		let anomaliesCleaned = GM_getValue("anomaliesCleaned", false);
		console.log({anomaliesCleaned});
		if (!anomaliesCleaned) {
			console.error("Guild XP/h: Cleaning anomalies");
			const keys = GM_listValues();
			keys.forEach((key) => {
				if (key != "anomaliesCleaned") {
					let value = GM_getValue(key);
					for (let name in value) {
						const xps = value[name];
						cleanAnomalies(xps);
					}
				}
			});
			anomaliesCleaned = true;
			GM_setValue("anomaliesCleaned", anomaliesCleaned);
		}
	}

	function minusDay (t) {
		return (new Date(t)).setDate((new Date(t)).getDate() - 1);
	}

	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) {
			const h = arr[arr.length - 1];
			const l = arr[arr.length - 2];
			const m = arr[arr.length - 3];
			const hld = h.xp - l.xp;
			const lmd = l.xp - m.xp;
			if (h.t - m.t < far && hld > lmd * 3) {
				console.error("Guild XP/h: Remove Anomalous datapoint");
				arr.splice(arr.length - 2, 1);
			}
		}
		*/

		// arr.length can get below 3 if an anomaly removed
		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 cleanAnomalies (arr, far=h1) {
		if (arr.length > 2) {
			for (let i = 2; i < arr.length; i++) {
				const h = arr[i];
				const l = arr[i-1];
				const m = arr[i-2];
				const hld = h.xp - l.xp;
				const lmd = l.xp - m.xp;
				if (h.t - m.t < far && hld > lmd * 3) {
					console.error("Guild XP/h: Remove Anomalous datapoint");
					//console.error({ d: new Date(m.t), xp: m.xp });
					//console.error("Remove", { d: new Date(l.t), xp: l.xp });
					//console.error({ d: new Date(h.t), xp: h.xp });
					//console.error({ lmd, hld });
					arr.splice(i - 1, 1);
					i -= 1;
				}
			}
		}
	}

	// Test pushXP() and cleanAnomalies() in Node.js
	/*
	function unsafeWindow () {} // Hoist
	let r = 2;
	let far = 5;
	let o = 11;
	let arr = [];
	pushXP(arr, { t: 8, xp: 17 }, r, far, o); // <- too old, delete
	pushXP(arr, { t: 9, xp: 18 }, r, far, o); // <- too old, delete
	pushXP(arr, { t: 10, xp: 19 }, r, far, o);
	pushXP(arr, { t: 11, xp: 20 }, r, far, o); // <- recent, don't add
	pushXP(arr, { t: 12, xp: 21 }, r, far, o);
	pushXP(arr, { t: 20, xp: 21 }, r, far, o);
	console.log("10, 12, 20", arr);

	arr = [
		{ t: 20, xp: 21 },
		{ t: 30, xp: 30 },
		{ t: 32, xp: 32 }, // <- should delete
		{ t: 34, xp: 40 }
	];
	o = 100;
	cleanAnomalies(arr, far);
	console.log("20, 30, 34", arr);

	arr = [
		{ t: 20, xp: 21 },
	]
	pushXP(arr, { t: 30, xp: 30 }, r, far, o);
	pushXP(arr, { t: 32, xp: 32 }, r, far, o);  // <- should delete, after next
	console.log("20, 30, 32", arr);
	pushXP(arr, { t: 34, xp: 40 }, r, far, o);
	console.log("20, 30, 34", arr);

	return;
	*/

	function keepOneInInterval (arr, interval) {
		let filtered = [];
		for (let i = arr.length - 1; i >= 0; i--) {
			const d = arr[i];
			if (filtered.length == 0) {
				filtered.unshift(d);
			} else if (filtered[0].t - d.t >= interval) {
				filtered.unshift(d);
			} else if (i == 0) {
				filtered.unshift(d);
			} else {
				// Skip
			}
		}
		
		return filtered;
	}

	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 calcIndividualStats (arr, options={}) {
		// all time min
		// all time max
		// last hour
		// last day
		// last week chart

		if (arr.length < 2) {
			return {
				lastHourXPH: 0,
				lastDayXPH: 0,
				minXPH: 0,
				maxXPH: 0,
				chart: [],
			};
		}

		const m10 = 10 * 60 * 1000;
		const d1 = 24 * 60 * 60 * 1000;
		const w1 = 7 * 24 * 60 * 60 * 1000;
		let temp = keepOneInInterval(inLastInterval(arr, w1), m10);


		let minXPH = Infinity;
		let maxXPH = 0;
		let chart = temp.map((d, i) => {
			if (i != 0) {
				const prev = temp[i - 1];
				const xpD = d.xp - prev.xp;
				const tD = d.t - prev.t;
				const xpH = (xpD / (tD / (60 * 1000))) * 60;
				minXPH = Math.min(minXPH, xpH);
				maxXPH = Math.max(maxXPH, xpH);

				return {
					t: d.t,
					tD,
					xpH,
				}
			}
		}).filter(Boolean);

		const lastXPH = arr.length >= 2 ? calcXPH(arr[arr.length - 2], arr[arr.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;


		const lastDayArr = inLastInterval(arr, d1);
		const lastDayXPH = lastDayArr.length >= 2 ? calcXPH(lastDayArr[0], lastDayArr[lastDayArr.length - 1]) : 0;

		return {
			lastXPH,
			lastHourXPH,
			lastDayXPH,
			minXPH,
			maxXPH,
			chart,
		};
	}

	function showTooltip ({ x, y, header, body }) {
		const dbb = document.body.getBoundingClientRect();
		let template = `<div role="tooltip"
			class="userscript-guild-xph__tooltip MuiPopper-root MuiTooltip-popper css-112l0a2"
			style="position: absolute; inset: auto auto 0px 0px; margin: 0px; transform: translate(${Math.floor(x - dbb.x)}px, ${Math.floor(y - dbb.bottom)}px) translate(-50%, 0);"
			data-popper-placement="top"
		>
			<div class="MuiTooltip-tooltip MuiTooltip-tooltipPlacementTop css-1spb1s5" style="opacity: 1; transition: opacity 0ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;">
				<div class="ItemTooltipText_itemTooltipText__zFq3A">
					<div class="ItemTooltipText_name__2JAHA">
						<span>${header}</span>
					</div>
					<div>
						<span>${body}</span>
					</div>
				</div>
			</div>
		</div>`

		hideTooltip();
		document.body.insertAdjacentHTML("beforeend", template);
	}

	function hideTooltip () {
		document.body.querySelectorAll(".userscript-guild-xph__tooltip").forEach((el) => el.remove());
	}

	function guildXPChart (chart) {
		if (chart.length == 0) {
			return "";
		}

		let maxXPH = 0;
		let tDSum = 0;
		let hasTruncated = false;
		if (chart.length >= 2) {
			const per50 = chart.slice(0).sort((a, b) => a.xpH - b.xpH)[Math.ceil(chart.length / 2)].xpH;

			chart.forEach((d) => {
				// Truncate data 2x the 50th percentile
				// For the big spike associated with the 19.08.2025 combat rework XP redistribution
				console.log(d.xpH, per50, d.xpH > per50, d.xpH > per50 * 2);
				if (d.xpH > per50 * 2) {
					d.truncated = true;
					hasTruncated = true;
				}
			});
		}

		chart.forEach((d) => {
			tDSum += d.tD;

			if (!d.truncated) {
				maxXPH = Math.max(maxXPH, d.xpH);
			}
		});

		if (hasTruncated) {
			maxXPH = maxXPH * 1.1;
		}

		const minT = chart[0].t;
		const maxT = chart[chart.length - 1].t;

		let hLegend = [];
		let lastDayStart = (new Date(maxT)).setHours(0, 0, 0, 0);
		let lt = lastDayStart;
		while (lt > minT) {
			if (hLegend.length == 0) {
				hLegend.unshift({
					t: lt,
				});
			} else {
				hLegend.unshift({
					t: lt,
				});
			}
			lt = minusDay(lt);
		}

		if (hLegend.length == 0) {
			// Always show min label
			hLegend.unshift({
				t: minT,
			});
		} else if (hLegend.length > 0 && hLegend[0].t - minT > tDSum / 10) {
			hLegend.unshift({
				t: minT,
			});
		}

		if (hLegend.length > 0 && maxT - hLegend[hLegend.length - 1].t > tDSum / 10) {
			hLegend.push({
				t: maxT,
			});
		}

		let t = `
			<div class="userscript-guild-xph" style="
					display: grid;
					grid-template-columns: auto auto 1fr;
					grid-template-rows: 1fr auto;
					width: calc(100% - 28px * 2);
					height: calc(100% - 28px * 3 - 14px);
					margin-top: 28px;
					margin-left: 28px;
					gap: 2px;
				">
				<div style="display: flex; flex-direction: column; justify-content: space-between; height: 100%;">
					<div style="font-size: 10px; transform: translate(0, -50%);">${f(maxXPH)}</div>
					<div style="font-size: 10px;">${f(maxXPH / 2)}</div>
					<div style="font-size: 10px; transform: translate(0, 50%);">${0}</div>
				</div>
				<div style="display: flex; flex-direction: column; justify-content: space-between; height: 100%;">
					<div style="width: 8px; height: 1px; background-color: var(--color-space-300);"></div>
					<div style="width: 8px; height: 1px; background-color: var(--color-space-300);"></div>
					<div style="width: 8px; height: 1px; background-color: var(--color-space-300);"></div>
				</div>
				<div style="flex: 1 1; display: flex; align-items: flex-end; height: 100%; gap: 1px;">`;
					chart.forEach((d) => {
						t += `<div class="userscript-guild-xph__bar"
							style="
								height: ${(d.truncated ? maxXPH : d.xpH) / maxXPH * 100}%;
								width: ${d.tD / tDSum * 100}%;
								${ d.truncated
									? 'background-image: linear-gradient(45deg, var(--color-space-300) 25%, transparent 25%, transparent 50%, var(--color-space-300) 50%, var(--color-space-300) 75%, transparent 75%); background-size: 10px 10px;'
									: 'background-color: var(--color-space-300);'
								}"
							data-xph="${d.xpH}"
							${d.truncated ? 'data-truncated="true"' : ''}
							data-t="${d.t}"
						></div>`;
					});
				t += `</div>
				<div></div>
				<div></div>
				<div style="flex: 0 0; position: relative; height: 28px;">`
					hLegend.forEach((d) => {
						t += `<div style="position: absolute; top: 0; left: ${(d.t - minT) / tDSum * 100}%; flex-direction: column;">
							<div style="width: 1px; height: 8px; background-color: var(--color-space-300);"></div>
							<div style="font-size: 10px; width: 80px; transform: translate(-50%, 0);">${new Date(d.t).toLocaleString()}</div>
						</div>`;
					});
				t += `</div>
			</div>`;

		return t;
	}

	function onBarEnter (event) {
		const el = event.target;
		const truncated = el.dataset.truncated === "true";
		const xpH = +el.dataset.xph;
		const t = +el.dataset.t;

		const bb = el.getBoundingClientRect();

		showTooltip({
			x: bb.x,
			y: bb.y,
			header: (new Date(t)).toLocaleString(),
			body: f(xpH) + " XP/h" + (truncated ? " (anomalously high value)" : "")
 		});
	}

	function onBarLeave (event) {
		hideTooltip();
	}

	function textColumnValueGetter (columnIndex, trEl) {
		return trEl.children[columnIndex].textContent;
	}

	function numberColumnValueGetter (columnIndex, trEl) {
		let n = trEl.children[columnIndex].textContent;
		n = n.replace(/ /, "");
		if (n.endsWith("K")) {
			return (+n) * 1000;
		} else {
			return +n;
		}
	}

	// For members natural sort order, activity
	function dataValueColumnValueGetter (columnIndex, trEl) {
		return trEl.children[columnIndex]._value;
	}

	function sortColumn (thEl, options) {
		const tableEl = thEl.parentElement.parentElement.parentElement;
		const tbodyEl = tableEl.querySelector("tbody");

		// Toggle direction + store selected sortId
		if (tableEl.dataset.sortId == options.sortId) {
			tableEl.dataset.sortDirection = tableEl.dataset.sortDirection == "asc" ? "desc" : "asc";
		} else {
			tableEl.dataset.sortId = options.sortId;
		}

		let trEls = Array.from(tbodyEl.children);

		// For leaderboards
		if (options.skipFirst) {
			trEls = trEls.slice(1);
		}

		trEls
			.sort((a, b) => {
				const av = options.sortGetter(a);
				const bv = options.sortGetter(b);
				if (typeof av == "number") {
					if (tableEl.dataset.sortDirection == "asc") {
						return av - bv;
					} else {
						return bv - av;
					}
				} else if (typeof av == "string"){
					if (tableEl.dataset.sortDirection == "asc") {
						return av.localeCompare(bv);
					} else {
						return bv.localeCompare(av);
					}
				} else {
					console.error("Guild XP/h: Should be unreachable");
				}
			})
			.forEach(trEl => tbodyEl.appendChild(trEl) );



		// Rerender sort icons
		const theadTrEl = thEl.parentElement;
		Array.from(theadTrEl.children).forEach((thEl) => {
			const iconEl = thEl.querySelector(".userscript-guild-xph__sort-icon");
			if (iconEl) {
				iconEl.remove();
				const template = sortIcon({ direction: thEl.dataset.sortId == tableEl.dataset.sortId ? tableEl.dataset.sortDirection : "none"});
				thEl.insertAdjacentHTML("beforeend", template)
			}
		});
	}

	function makeColumnSortable(thEl, options = {}) {
		if (!("icon" in options)) {
			options.icon = true;
		}

		const theadTrEl = thEl.parentElement;
		const columnIndex = Array.from(theadTrEl.children).indexOf(thEl);

		if (!("showIcon" in options)) {
			options.showIcon = true;
		}

		if (!("skipFirst" in options)) {
			options.skipFirst = false;
		}

		if (!("sortId" in options)) {
			options.sortId = columnIndex;
		}

		if (!("sortGetter" in options)) {
			if (options.sortData || options.data) {
				options.sortGetter = dataValueColumnValueGetter.bind(null, columnIndex);
			} else {
				options.sortGetter = textColumnValueGetter.bind(null, columnIndex);
			}
		}

		const tableEl = thEl.parentElement.parentElement.parentElement;
		if (options.defaultSortId) {
			tableEl.dataset.sortId = options.defaultSortId;
		}

		if (options.sortData) {
			const tbodyEl = tableEl.querySelector("tbody");
			Array.from(tbodyEl.children).forEach((trEl, i) => {
				trEl.children[columnIndex]._value = options.sortData[i];
			});
		} else if (options.data) {
			const tbodyEl = tableEl.querySelector("tbody");
			Array.from(tbodyEl.children).forEach((trEl, i) => {
				trEl.children[columnIndex]._value = options.data[i];
			});
		}

		thEl.dataset.sortId = options.sortId;
		tableEl.dataset.sortDirection = "desc";

		if (options.showIcon) {
			const template = sortIcon({ direction: thEl.dataset.sortId == tableEl.dataset.sortId ? tableEl.dataset.sortDirection : "none"});
			thEl.insertAdjacentHTML("beforeend", template)
		}

		thEl.style.cursor = "pointer";
		thEl.onclick = sortColumn.bind(null, thEl, options);
	}

	function addColumn(tableEl, options) {
		let thEl = tableEl.querySelector(`th.userscript-guild-xph[data-name="${options.name}"`)
		if (!thEl) {
			const theadTrEl = tableEl.querySelector("thead tr");

			if (!("insertAfter" in options) && !("insertBefore" in options)) {
				options.insertAfter = theadTrEl.children.length - 1;
			}

			const template = `<th class="userscript-guild-xph">${options.name}</th>`;
			if (options.insertBefore) {
				theadTrEl.children[options.insertBefore].insertAdjacentHTML("beforebegin", template);
			} else {
				theadTrEl.children[options.insertAfter].insertAdjacentHTML("afterend", template);
			}

			const tbodyEl = tableEl.querySelector("tbody");
			Array.from(tbodyEl.children).forEach((trEl, i) => {
				const v = options.data[i];
				let fv;
				if (options.format) {
					fv = options.format(v, i);
				} else if (v === undefined) {
					fv = "";
				} else {
					fv = typeof v == "number" ? f(v) : (v ?? "0");
				}
				const template = `<td class="userscript-guild-xph">${fv}</td>`
				if (options.insertBefore) {
					trEl.children[options.insertBefore].insertAdjacentHTML("beforebegin", template);
				} else {
					trEl.children[options.insertAfter].insertAdjacentHTML("afterend", template);
				}
			});

			if (options.makeSortable) {
				if (options.insertBefore) {
					makeColumnSortable(theadTrEl.children[options.insertBefore], options);
				} else {
					makeColumnSortable(theadTrEl.children[options.insertAfter + 1], options);
				}
			}
		}
	}

	function fTimeLeft (ms) {
		const m1 = 60 * 1000;
		const h1 = 60 * 60 * 1000;
		const d1 = 24 * 60 * 60 * 1000;
		const w1 = 7 * 24 * 60 * 60 * 1000;

		const w = Math.floor(ms / w1);
		const d = Math.floor((ms % w1) / d1);
		const h = Math.floor((ms % d1) / h1);
		const m = Math.ceil((ms % h1) / m1);

		const s = (n) => ("" + n).endsWith("1") ? "" : "s";

		let f = [];

		if (w >= 1) {
			f.push(`${w} week${s(w)}`);
		}

		if (d >= 1) {
			f.push(`${d} day${s(d)}`);
		}

		if (ms < w1 && h >= 1) {
			f.push(`${h} hour${s(h)}`);
		}

		if (ms < 6 * h1 && m >= 1) {
			f.push(`${m} minute${s(m)}`);
		}

		return f.join(" ");
	}

	// Test in Node
	/*
	console.log(fTimeLeft(1000));
	console.log(fTimeLeft(2 * 60 * 1000));
	console.log(fTimeLeft(51 * 60 * 1000));
	console.log(fTimeLeft(59 * 60 * 1000));
	console.log();

	console.log(fTimeLeft(1 * 60 * 60 * 1000));
	console.log(fTimeLeft(1.5 * 60 * 60 * 1000));
	console.log(fTimeLeft(1 * 60 * 60 * 1000 + 1 * 60 * 1000));
	console.log(fTimeLeft(1 * 60 * 60 * 1000 + 21 * 60 * 1000));
	console.log();

	console.log(fTimeLeft(5 * 60 * 60 * 1000));
	console.log(fTimeLeft(5 * 60 * 60 * 1000 + 21 * 60 * 1000));
	console.log(fTimeLeft(24 * 60 * 60 * 1000));
	console.log();

	console.log(fTimeLeft(24 * 60 * 60 * 1000 + 5 * 60 * 60 * 1000));
	console.log(fTimeLeft(7 * 24 * 60 * 60 * 1000));
	console.log();
	
	console.log(fTimeLeft(7 * 24 * 60 * 60 * 1000 + 12 * 60 * 60 * 1000));
	console.log(fTimeLeft(7 * 24 * 60 * 60 * 1000 + 24 * 60 * 60 * 1000));
	console.log();

	console.log(fTimeLeft(2 * 7 * 24 * 60 * 60 * 1000));
	console.log(fTimeLeft(2.5 * 7 * 24 * 60 * 60 * 1000));
	console.log();
	*/

	function fPlace (n) {
		if (n <= 3) {
			return ["🥇", "🥈", "🥉"][n - 1];
		} else {
			return `<span class="userscript-guild-xph" style="color: var(--color-disabled);">#${n}</span>`;
		}
	}

	async function onOverviewClick () {
		//console.log("onOverviewClick");

		await waitFor(".GuildPanel_dataGrid__11Jpe");

		let guildsXP = GM_getValue("guildsXP", {});
		debugValue("guildsXP", guildsXP);

		const stats = calcIndividualStats(guildsXP[ownGuildName]);
		debugValue("stats", stats)

		const template = `
			<div class="GuildPanel_dataBlockGroup__1d2rR userscript-guild-xph">
				<div class="GuildPanel_dataBlock__3qVhK">
					<div class="GuildPanel_label__-A63g">${stats.lastHourXPH > 0 ? "Last hour XP/h" : "Last XP/h"}</div>
					<div class="GuildPanel_value__Hm2I9">${f(stats.lastHourXPH > 0 ? stats.lastHourXPH : stats.lastHourXPH)}</div>
				</div>
				<div class="GuildPanel_dataBlock__3qVhK">
					<div class="GuildPanel_label__-A63g">Last day XP/h</div>
					<div class="GuildPanel_value__Hm2I9">${f(stats.lastDayXPH)}</div>
				</div>
			</div>
			<div class="GuildPanel_dataBlockGroup__1d2rR userscript-guild-xph">
				<div class="GuildPanel_dataBlock__3qVhK">
					<div class="GuildPanel_label__-A63g">Min XP/h</div>
					<div class="GuildPanel_value__Hm2I9">${f(stats.minXPH)}</div>
				</div>
				<div class="GuildPanel_dataBlock__3qVhK">
					<div class="GuildPanel_label__-A63g">Max XP/h</div>
					<div class="GuildPanel_value__Hm2I9">${f(stats.maxXPH)}</div>
				</div>
			</div>
			<div class="GuildPanel_dataBlockGroup__1d2rR userscript-guild-xph" style="grid-column: 1 / 3; max-width: none;">
				<div class="GuildPanel_dataBlock__3qVhK" style="height: 240px;">
					<div class="GuildPanel_label__-A63g">Last week XP/h</div>
					${guildXPChart(stats.chart)}
				</div>
			</div>
		`;

		const dataGridEl = document.querySelector(".GuildPanel_dataGrid__11Jpe");
		dataGridEl.querySelectorAll(".userscript-guild-xph").forEach((el) => el.remove());
		dataGridEl.insertAdjacentHTML("beforeend", template);
		dataGridEl.querySelectorAll(".userscript-guild-xph__bar").forEach((el) => {
			el.onmouseenter = onBarEnter;
			el.onmouseleave = onBarLeave;
		});

		unsafeWindow.levelExperienceTable = levelExperienceTable;
		const currentXp = guildsXP[ownGuildName][guildsXP[ownGuildName].length - 1].xp;

		const nextLvlIndex = levelExperienceTable.findIndex((xp) => currentXp <= xp);
		const xpTillLvl = levelExperienceTable[nextLvlIndex] - currentXp;
		const h1 = 60 * 60 * 1000;
		const msTillLvl = xpTillLvl / stats.lastDayXPH * h1;

		const template2 = `
			<div class="userscript-guild-xph" style="color: var(--color-space-300);">${fTimeLeft(msTillLvl)}</div>
		`;

		const expToLvlEl = dataGridEl.querySelector(".GuildPanel_dataBlockGroup__1d2rR:nth-child(2) .GuildPanel_dataBlock__3qVhK:nth-child(1)");
		expToLvlEl.querySelector(".userscript-guild-xph")?.remove();
		expToLvlEl.insertAdjacentHTML("beforeend", template2);


		const template3 = `
			<span class="userscript-guild-xph" style="color: var(--color-disabled);font-size: 14px;font-weight: 400;">${"making cheese since " +  (new Date(guildCreatedAt)).toLocaleDateString()}</span>
		`;

		const guildNameEl = document.querySelector(".GuildPanel_guildName__E5D_h");
		guildNameEl.querySelector(".userscript-guild-xph")?.remove();
		guildNameEl.insertAdjacentHTML("beforeend", template3);

	}


	function sortIcon ({ direction = "none" } = {}) {
		return `
			<span class="userscript-guild-xph userscript-guild-xph__sort-icon" style="display: inline-flex; flex-direction: column; vertical-align: middle">
				<span style="font-size: 8px; line-height: 8px;">${direction == "asc" ? "▲" : "△"}</span>
				<span style="font-size: 8px; line-height: 8px;">${direction == "desc" ? "▼" : "▽"}</span>
			</span>
		`;
	}

	let membersSort = "natural";
	function onMembersSort () {
		const el = event.target;
		const column = el.dataset.column;

		membersSort = column;
	}

	async function onMembersClick () {
		//console.log("onMembersClick");

		await waitFor(".GuildPanel_membersTable__1NwIX");

		const containerEl = document.querySelector(".GuildPanel_membersTab__2ax4-");

		if (containerEl.querySelector(".userscript-guild-xph")) {
			return; // Already modded
		}

		// Make table wider
		containerEl.style.maxWidth = "1100px";

		let membersXP = GM_getValue("membersXP_"+ownGuildID, {});
		debugValue("membersXP", membersXP);

		const tableEl = document.querySelector(".GuildPanel_membersTable__1NwIX");

		let allStats = [];
		const tbodyEl = tableEl.querySelector("tbody");
		Array.from(tbodyEl.children).forEach((trEl, i) => {
			const name = trEl.children[0].textContent;
			const id = nameToId[name];
			const stats = calcIndividualStats(membersXP[id]);
			stats.i = i;
			stats.xp = membersXP[id][membersXP[id].length - 1].xp;
			stats.gameMode = nameToGameMode[name];
			stats.joinTime = nameToJoinTime[name];
			//stats.name = name;
			allStats.push(stats);
		});

		allStats.slice(0).sort((a, b) => b.lastXPH - a.lastXPH).forEach((stats, i) => stats.lastXPH_N = i + 1);
		allStats.slice(0).sort((a, b) => b.lastDayXPH - a.lastDayXPH).forEach((stats, i) => stats.lastDayXPH_N = i + 1);

		const theadTrEl = tableEl.querySelector("thead tr");

		// Name - sort in natural order
		makeColumnSortable(theadTrEl.children[0], {
			defaultSortId: "index",
			sortId: "index",
			data: allStats.map((s) => s.i),
		});

		// Role - sort in natural order
		makeColumnSortable(theadTrEl.children[1], {
			sortId: "index",
			sortGetter: dataValueColumnValueGetter.bind(null, 0),
			showIcon: false,
		});

		// Guild Exp
		makeColumnSortable(theadTrEl.children[2], {
			sortId: "xp",
			data: allStats.map((s) => s.xp),
		});

		// Game Mode
		addColumn(tableEl, {
			insertBefore: Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Activity"),
			name: "Game Mode",
			data: allStats.map((d) => d.gameMode),
			format: (d, i) => {
				const modes = {
					"standard": "MC",
					"ironcow": "IC",
					"legacy_ironcow": "LC",
				};
				return modes[d];
			},
			makeSortable: true,
			sortId: "gameMode",
		});

		// Joined
		addColumn(tableEl, {
			insertBefore: Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Activity"),
			name: "Joined",
			data: allStats.map((d) => d.joinTime),
			sortData: allStats.map((d) => +(new Date(d.joinTime))),
			format: (d, i) => {
				return (new Date(d)).toLocaleDateString();
			},
			makeSortable: true,
			sortId: "joinTime",
		});

		// Last XP + #
		addColumn(tableEl, {
			insertBefore: Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Activity"),
			name: "Last XP/h",
			data: allStats.map((d) => d.lastXPH),
			format: (d, i) => {
				if (d == 0) {
					return ""
				}
				let n = allStats[i].lastXPH_N;
				return f(d) + " " + fPlace(n);
			},
			makeSortable: true,
			sortId: "last",
		});

		// Last day XP + #
		addColumn(tableEl, {
			insertBefore: Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Activity"),
			name: "Last day XP/h",
			data: allStats.map((d) => d.lastDayXPH),
			format: (d, i) => {
				if (d == 0) {
					return ""
				}
				let n = allStats[i].lastDayXPH_N;
				return f(d) + " " + fPlace(n);
			},
			makeSortable: true,
			sortId: "day",
		});

		// Max XP/h
		addColumn(tableEl, {
			insertBefore: Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Activity"),
			name: "Max XP/h",
			data: allStats.map((d) => d.maxXPH),
			format: (d, i) => {
				if (d == 0) {
					return ""
				} else {
					return f(d);
				}
			},
			makeSortable: true,
			sortId: "max",
		});

		// Activity
		const activities = [
			"idle",
			"milking",
			"foraging",
			"woodcutting",
			"cheesesmithing",
			"crafting",
			"tailoring",
			"cooking",
			"brewing",
			"alchemy",
			"enhancing",
			"combat",
		];

		const activityIndex = Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Activity");
		const activityData = Array.from(tableEl.querySelector("tbody").children).map((trEl) => {
			const columnIndex = activityIndex;
			const activity =  trEl.children[activityIndex].querySelector("use")?.getAttribute("href")?.split("#")?.[1];
			if (activity) {
				return activities.indexOf(activity);
			} else if (trEl.children[columnIndex].textContent == "") {
				return activities.indexOf("idle");
			} else {
				// Parse "23d ago", use as a negative value when sorting
				return -(parseInt(trEl.children[columnIndex].textContent));
			}
		});

		makeColumnSortable(Array.from(theadTrEl.children).find((el) => el.textContent == "Activity"), {
			sortId: "activity",
			showIcon: true,
			sortData: activityData,
		});

		// Status
		const statuses = [
			"Offline",
			"Hidden",
			"Online",
		];

		const statusIndex = Array.from(theadTrEl.children).findIndex((el) => el.textContent == "Status");
		const statusData = Array.from(tableEl.querySelector("tbody").children).map((trEl) => {
			const columnIndex = statusIndex;
			return statuses.indexOf(trEl.children[columnIndex].textContent);
		});

		makeColumnSortable(Array.from(theadTrEl.children).find((el) => el.textContent == "Status"), {
			sortId: "status",
			showIcon: true,
			sortData: statusData,
		});
	}

	async function onGuildClick () {
		await waitFor(".GuildPanel_tabsComponentContainer__1JjQu");

		const overviewTabEl = document.querySelector(".GuildPanel_tabsComponentContainer__1JjQu .MuiButtonBase-root:nth-child(1)");
		overviewTabEl.onclick = onOverviewClick;

		const membersTabEl = document.querySelector(".GuildPanel_tabsComponentContainer__1JjQu .MuiButtonBase-root:nth-child(2)");
		membersTabEl.onclick = onMembersClick;

		onOverviewClick();
	}

	async function onLeaderboardClick () {
		await waitFor(".LeaderboardPanel_leaderboardTable__3JLvu");

		const containerEl = document.querySelector(".LeaderboardPanel_content__p_WNw");

		if (containerEl.querySelector(".userscript-guild-xph")) {
			return; // Already modded
		}

		// Make table wider
		containerEl.style.maxWidth = "1000px";

		let guildsXP = GM_getValue("guildsXP", {});
		debugValue("guildsXP", guildsXP);

		const tableEl = document.querySelector(".LeaderboardPanel_leaderboardTable__3JLvu");

		let allStatsObj = {};
		const tbodyEl = tableEl.querySelector("tbody");
		Array.from(tbodyEl.children).forEach((trEl, i) => {
			const name = trEl.children[1].textContent;
			const stats = calcIndividualStats(guildsXP[name]);
			stats.rank = i + 1;
			allStatsObj[name] = stats;
		});

		Object.values(allStatsObj).sort((a, b) => b.lastXPH - a.lastXPH).forEach((stats, i) => stats.lastXPH_N = i + 1);
		Object.values(allStatsObj).sort((a, b) => b.lastDayXPH - a.lastDayXPH).forEach((stats, i) => stats.lastDayXPH_N = i + 1);

		let allStats = [];
		Array.from(tbodyEl.children).forEach((trEl, i) => {
			const name = trEl.children[1].textContent;
			allStats.push(allStatsObj[name]);
		});

		const theadTrEl = tableEl.querySelector("thead tr");

		// Rank
		makeColumnSortable(Array.from(theadTrEl.children).find((el) => el.textContent == "Rank"), {
			defaultSortId: "rank",
			sortId: "rank",
			showIcon: true,
			skipFirst: true,
			sortGetter: numberColumnValueGetter.bind(null, 0),
		});

		// Skip Name

		// Level - sort by rank
		makeColumnSortable(Array.from(theadTrEl.children).find((el) => el.textContent == "Level"), {
			sortId: "rank",
			showIcon: false,
			skipFirst: true,
			sortGetter: numberColumnValueGetter.bind(null, 0), // Sort by rank
		});

		// Experience - sort by rank
		makeColumnSortable(Array.from(theadTrEl.children).find((el) => el.textContent == "Experience"), {
			sortId: "rank",
			showIcon: false,
			skipFirst: true,
			sortGetter: numberColumnValueGetter.bind(null, 0), // Sort by rank
		});

		// Last XP + #
		addColumn(tableEl, {
			name: "Last XP/h",
			data: allStats.map((d) => d.lastXPH),
			format: (d, i) => {
				if (d == 0) {
					return ""
				}
				let n = allStats[i].lastXPH_N;
				return f(d) + " " + fPlace(n);
			},
			makeSortable: true,
			skipFirst: true,
			sortId: "last",
		});

		// Last day XP + #
		addColumn(tableEl, {
			name: "Last day XP/h",
			data: allStats.map((d) => d.lastDayXPH),
			format: (d, i) => {
				if (d == 0) {
					return ""
				}
				let n = allStats[i].lastDayXPH_N;
				return f(d) + " " + fPlace(n);
			},
			makeSortable: true,
			sortId: "day",
			skipFirst: true,
		});
	}

	async function onSettingsClick () {
		await waitFor(".SettingsPanel_tabsComponentContainer__Xb_5H");

		const profileTabEl = document.querySelector(".SettingsPanel_tabsComponentContainer__Xb_5H .MuiButtonBase-root:nth-child(1)");
		profileTabEl.onclick = onSettingsClick;

		const profileEl = document.querySelector(".SettingsPanel_profileTab__214Bj");

		const template = `
			<div class="SettingsPanel_infoGrid__2nh1u userscript-guild-xph">
				<h3 style="grid-column: 1 / 3; margin-top: 40px;">Guild XP/h userscript settings:</h3>

				<div class="SettingsPanel_label__24LRD">Export to file:</div>
				<div class="SettingsPanel_value__2nsKD">
					<button class="Button_button__1Fe9z userscript-guild-xph__export">Export</button>
				</div>

				<div class="SettingsPanel_label__24LRD">Import from file:</div>
				<div class="SettingsPanel_value__2nsKD">
					<button class="Button_button__1Fe9z userscript-guild-xph__import">Import</button>
				</div>

				<div class="SettingsPanel_label__24LRD">Delete ALL data:</div>
				<div class="SettingsPanel_value__2nsKD">
					<button class="Button_button__1Fe9z userscript-guild-xph__reset">Delete</button>
				</div>
			</div>
		`;

		profileEl.querySelector(".userscript-guild-xph")?.remove();
		profileEl.insertAdjacentHTML("beforeend", template);

		profileEl.querySelector(".userscript-guild-xph .userscript-guild-xph__export").onclick = exportData;
		profileEl.querySelector(".userscript-guild-xph .userscript-guild-xph__import").onclick = importData;
		profileEl.querySelector(".userscript-guild-xph .userscript-guild-xph__reset").onclick = reset;
	}

	let ownGuildName;
	let ownGuildID;
	let nameToId = {};
	let nameToGameMode = {};
	let nameToJoinTime = {};
	let whoInvited = {};
	let guildCreatedAt;


	let characterName;
	function handle (message) {
		if (message.type == "init_character_data") {
			characterName = message.character.name;
		}

		if (message.type == "guild_updated" || message.type == "init_character_data") {
			//console.log(message);

			let t = Date.now();

			let guildsXP = GM_getValue("guildsXP", {});

			const name = message.guild.name;
			const xp = message.guild.experience;
			guildCreatedAt = message.guild.createdAt;

			ownGuildName = name;

			if (!guildsXP[name]) {
				guildsXP[name] = [];
			}

			const d = { t, xp };
			pushXP(guildsXP[name], d);

			GM_setValue("guildsXP", guildsXP);
		}

		// Intentionally not if/else, cause of "init_character_data"
		if (message.type == "guild_characters_updated" || message.type == "init_character_data") {
			//console.log(message);

			let t = Date.now();

			const guildID = Object.values(message.guildCharacterMap)[0].guildID;

			ownGuildID = guildID;

			Object.entries(message.guildSharableCharacterMap).forEach(([characterID, d]) => {
				nameToId[d.name] = characterID;
				nameToGameMode[d.name] = d.gameMode;
				nameToJoinTime[d.name] = message.guildCharacterMap[characterID].joinTime;
			});

			let membersXP = GM_getValue("membersXP_"+guildID, {});

			Object.values(message.guildCharacterMap).forEach((c) => {
				const id = c.characterID;
				const xp = c.guildExperience;

				if (!membersXP[id]) {
					membersXP[id] = [];
				}

				const d = { t, xp };
				pushXP(membersXP[id], d);
			});

			GM_setValue("membersXP_"+guildID, membersXP);
		}

		if (message.type == "guild_updated") {
			onGuildClick();
		}

		if (message.type == "leaderboard_updated" && message.leaderboardCategory == "guild") {
			//console.log(message);

			const t = Date.now();

			let guildsXP = GM_getValue("guildsXP", {});

			message.leaderboard.rows.forEach((r) => {
				const name = r.name;
				const xp = r.value2;

				if (!guildsXP[name]) {
					guildsXP[name] = [];
				}

				const d = { t, xp };
				pushXP(guildsXP[name], d);
			});

			GM_setValue("guildsXP", guildsXP);

			onLeaderboardClick();
		}
	}

	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("Guild XP/h: Wrapped window.WebSocket");
	console.log("Guild XP/h: Set window.guildXPUserscriptDebug = true; - to see debug logging");

	//cleanData();

	(async function () {
		const settingsEl = await waitFor(`.NavigationBar_navigationLink__3eAHA:has(svg[aria-label="navigationBar.settings"])`);
		settingsEl.addEventListener("click", onSettingsClick);
	})();

	let levelExperienceTable = [];
	(() => {
		const initClientData = unsafeWindow.localStorage.getItem("initClientData");
		if (!initClientData) {
			throw new Error("Guild XP/h: was not able to load initClientData");
		}
		levelExperienceTable = JSON.parse(initClientData).levelExperienceTable;
	})();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址