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