// ==UserScript==
// @name Stats, Shards, XP, Dust, Quest, Res and level tracker
// @namespace http://tampermonkey.net/
// @version 2.3.2
// @description Tracks XP/hr, XP/day, Dust/hr, Dust/day, TNL estimate, and Quest time, as well as shards/hr, shards/day, stats/hr, total stats
// @icon https://www.google.com/s2/favicons?sz=64&domain=manarion.com
// @match *://manarion.com/*
// @grant none
// @author Elnaeth
// @license MIT
// ==/UserScript==
/*
======= Changelog =======
v2.3.2
Changed date parsing to try and accomodate more country formats
v2.3.1
Updated tracker injection code to try to locate it and retry injection until it succeeds
v2.3.0
Many changes and updates to date and time and number format parsing,
not all browsers/countries adhere to the same standards (fuck ISO8601 amirite?)
*/
(function () {
'use strict';
let isBattling = false;
let isGathering = false;
// track ticks that have passed
let ticks = 0;
// simple tracking for last known XP/Dust/Resource amounts, since those can be multiplied by ticks to extrapolate to longer periods
let lastXP = null;
let lastDust = null;
let lastResource = null;
// keep track of player gained stats
const stats = {
Total: 0,
Intellect: { start: 0, current: 0, gained: 0, mastery: false},
Stamina: { start: 0, current: 0, gained: 0, mastery: false},
Spirit: { start: 0, current: 0, gained: 0, mastery: false},
Focus: { start: 0, current: 0, gained: 0, mastery: false},
Mana: { start: 0, current: 0, gained: 0, mastery: false},
// we also track mastery drops, but these don't count for the stats/hr calcs
'Water Mastery' : { start: 0, current: 0, gained: 0, mastery: true},
'Fire Mastery' : { start: 0, current: 0, gained: 0, mastery: true},
'Nature Mastery': { start: 0, current: 0, gained: 0, mastery: true},
};
// create a flat map of all stat names for later use
const statNames = Object.keys(stats);
// function that formats huge numbers humanly-readable
const formatNumber = (num) => {
if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T';
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
return Math.round(num).toString();
};
// converts minutes to 1h 30m strings
const formatTime = (minutesTotal) => {
const hours = Math.floor(minutesTotal / 60);
const minutes = Math.floor(minutesTotal % 60);
return `${hours}h ${minutes}m`;
};
// calculate time to next level
const getTNL = () => {
const xpSpan = document.querySelector('span.break-all');
if (!xpSpan) return null;
const spanTitles = xpSpan.querySelectorAll('span[title]');
if (spanTitles.length < 2) return null;
const current = parseInt(spanTitles[0].title.replace(/,/g, ''));
const next = parseInt(spanTitles[1].title.replace(/,/g, ''));
return isNaN(current) || isNaN(next) ? null : next - current;
};
// parse base stat values from the left menu hover texts
const getStats = () => {
// first see if we can locate the main menu
const statsDivs = document.querySelectorAll('div.col-span-2.flex.justify-between');
if (!statsDivs) return null;
// reset for this iteration
stats.Total = 0;
// loop over all stats rows
for (const div of statsDivs) {
// loop over all stat names to find if the current row matches one of the stats we're looking for
for (const stat of statNames) {
// skip the running total meta-stat
if (stat === "Total") continue;
const nameSpans = div.querySelectorAll('span');
if (!nameSpans) continue;
// see if it matches an exact stat
if (nameSpans[0].textContent.replace(':', '') !== stat) continue;
// find the span with the mouseover hover title
const titleSpan = div.querySelector('span[title]');
// strip the leading Base value text
let baseValue = titleSpan.title.replace('Base value ', '');
// special handling for mastery stats
if (baseValue.includes('%')) {
baseValue = parseFloat(baseValue) * 100;
}
baseValue = parseInt(baseValue);
// for the first iteration all stats will be at 0, we take the start on page refresh
if (stats[stat].start === 0 && stats[stat].start === stats[stat].current) {
stats[stat].start = baseValue;
stats[stat].current = baseValue;
stats[stat].gained = 0;
} else {
// we have already found the start value, now keep track of the current value
stats[stat].current = baseValue;
// finally, we can see if the start value is no longer the current base value, so calculate gains
if (stats[stat].start !== baseValue) {
stats[stat].gained = stats[stat].current - stats[stat].start;
// add it to the total gains
if (!stats[stat].mastery) stats.Total += stats[stat].gained;
}
}
}
}
return stats;
};
// parse the entire content of the loot tracker for all shard entries and calculate rates
const parseShards = () => {
const output = {
first: null,
last: new Date(), // the last drop of the day is always statically taken as current system time for more accuracy
elapsedSeconds: 0,
total: 0,
};
// locate the loot tracker window
const lootTracker = document.querySelector('div.scrollbar-none.scrollbar-track-transparent.h-60.grow-1.overflow-x-hidden.overflow-y-auto');
if (!lootTracker) return output;
// run over all common drops
const possibleShardDrops = lootTracker.querySelectorAll('div.rarity-common');
for (const lootDrop of possibleShardDrops) {
// then, match by name, skip parsing amount if it's not a shards drop
const lootName = lootDrop.querySelector('span.rarity-common').textContent;
if (!lootName.includes('Elemental Shard')) continue;
// parse out date and time of the drop, to create a sliding window of loot drops
const timestampPart = lootDrop.querySelector('span.text-xs span[title]');
const date = timestampPart.title;
const time = timestampPart.textContent;
const foundAt = Date.parse(date + ' ' + time);
// keep track of this loot as the first of the day, and next iterations
// will overwrite it with the earlier one as we go down the list
output.first = foundAt;
// parse out the amount from the title hover attribute
// TODO remove the space-replacer at the end, that's only for Cyrillic users
const amount = lootDrop.querySelector('span.text-foreground span[title]').title.replace(',', '').replace(' ', '');
output.total += parseInt(amount);
}
// calculate the time between first and last tracked shard drop
if (output.first && output.last) {
output.elapsedSeconds = (output.last - output.first) / 1000;
}
return output;
};
const getQuestTime = () => {
// locate the quest progress component
const possibleQuestContainers = Array.from(document.querySelectorAll('p.text-foreground.text-sm'));
// keep track of what kind of thing we're doing right now
isBattling = possibleQuestContainers.some(p => p.textContent.includes('Defeat'));
isGathering = possibleQuestContainers.some(p => p.textContent.includes('harvests'));
// then find the container so we can parse out the current quest progress
const questProgress = possibleQuestContainers.find(p => p.textContent.includes('Defeat') || p.textContent.includes('harvests'));
if (!questProgress && !isBattling && !isGathering) return null;
// extract the number requirements
const match = questProgress.textContent.match(/(\d+)\s*\/\s*(\d+)/);
if (!match) return null;
const current = parseInt(match[1]);
const total = parseInt(match[2]);
if (isNaN(current) || isNaN(total) || current >= total) return 0;
const remaining = total - current;
const seconds = remaining * 3;
return formatTime(seconds / 60);
};
const createTrackerContainer = () => {
// look for the entire menu on the left of the screen
const leftMenu = document.querySelector('div.grid.grid-cols-4');
if (!leftMenu) return;
// only create if it does not already exist
const exists = document.getElementById('elnaeth-tracker');
if (exists) return;
// create our tracker container
const tracker = document.createElement("div");
tracker.innerHTML = `
<div id="elnaeth-tracker" style="display: none;"></div>
<hr width="100%" style="border: 1px solid #fff; margin: 10px 0 10px 0;" />
<div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-3 text-sm lg:grid-cols-2">
<div class="flex col-span-2 justify-between"><span>Quest Timer:</span><span id="quest-timer">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Time to Level:</span><span id="xp-tnl">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>XP / Hr:</span><span id="xp-rate">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>XP / Day:</span><span id="xp-day-rate">Calculating...</span></div>
<div class="battle-specific flex col-span-2 justify-between"><span>Mana Dust / Hr:</span><span id="dust-rate">Calculating...</span></div>
<div class="battle-specific flex col-span-2 justify-between"><span>Mana Dust / Day:</span><span id="dust-day-rate">Calculating...</span></div>
<div class="gather-specific flex col-span-2 justify-between"><span>Resource / Hr:</span><span id="resource-rate">Calculating...</span></div>
<div class="gather-specific flex col-span-2 justify-between"><span>Resource / Day:</span><span id="resource-day-rate">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Shards / Hr:</span><span id="shard-rate">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Shards / Day:</span><span id="shard-day-rate">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Loot tracker shards:</span><span id="shards-found">Calculating...</span></div>
</div>
<hr width="100%" style="border: 1px solid #fff; margin: 10px 0 10px 0;" />
<div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-3 text-sm lg:grid-cols-2">
<div class="flex col-span-2 justify-between"><span>Intellect gained:</span><span id="intellect-gained">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Stamina gained:</span><span id="stamina-gained">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Spirit gained:</span><span id="spirit-gained">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Focus gained:</span><span id="focus-gained">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Mana gained:</span><span id="mana-gained">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Mastery gained:</span><span id="mastery-gained">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Tracked ticks:</span><span id="tracked-ticks">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Total stats:</span><span id="stats-hr">Calculating...</span></div>
</div>`;
leftMenu.insertAdjacentElement('afterend', tracker);
};
const updateTracker = () => {
// create the tracker HTML elements
createTrackerContainer();
// show/hide battler specific stats depending on if we're battling
const battleContainers = document.getElementsByClassName('battle-specific');
for (let container of battleContainers) {
container.style.display = isBattling ? 'flex' : 'none';
}
// show/hide gatherer specific stats depending on if we're gathering
const gatherContainers = document.getElementsByClassName('gather-specific');
for (let container of gatherContainers) {
container.style.display = isGathering ? 'flex' : 'none';
}
// Calculate rates
const xpPerHr = lastXP ? lastXP * 1200 : 0;
const xpPerDay = xpPerHr * 24;
const dustPerHr = lastDust ? lastDust * 1200 : 0;
const dustPerDay = dustPerHr * 24;
const resourcePerHr = lastResource ? lastResource * 1200 : 0;
const resourcePerDay = resourcePerHr * 24;
const tnl = getTNL();
const minutesToLevel = xpPerHr > 0 && tnl ? (tnl / xpPerHr) * 60 : null;
const questTime = getQuestTime();
// Calculate percentage to next level
let percentageToLevel = 'N/A';
if (tnl && xpPerHr > 0) {
const current = parseInt(document.querySelectorAll('span[title]')[0].title.replace(/,/g, ''));
percentageToLevel = ((current / (current + tnl)) * 100).toFixed(2) + '%';
}
// update level calculation and quest timer
document.getElementById('xp-tnl').textContent = minutesToLevel ? `${formatTime(minutesToLevel)} (${percentageToLevel})` : 'Calculating...';
document.getElementById('quest-timer').textContent = questTime ?? 'N/A';
// update xp, dust and resource rates
document.getElementById('xp-rate').textContent = xpPerHr ? formatNumber(xpPerHr) : 'Calculating...';
document.getElementById('xp-day-rate').textContent = xpPerDay ? formatNumber(xpPerDay) : 'Calculating...';
document.getElementById('dust-rate').textContent = dustPerHr ? formatNumber(dustPerHr) : 'Calculating...';
document.getElementById('dust-day-rate').textContent = dustPerDay ? formatNumber(dustPerDay) : 'Calculating...';
document.getElementById('resource-rate').textContent = resourcePerHr ? formatNumber(resourcePerHr) : 'Calculating...';
document.getElementById('resource-day-rate').textContent = resourcePerDay ? formatNumber(resourcePerDay) : 'Calculating...';
const stats = getStats();
const shards = parseShards();
// calculate stats per hour
if (ticks > 0) {
const secondsPassed = ticks * 3;
const hoursPassed = secondsPassed / 3600;
const statsPerHour = stats.Total / hoursPassed;
const tookTime = formatTime(secondsPassed / 60);
// show stats per hour and total
document.getElementById('stats-hr').textContent = stats.Total + ' (' + statsPerHour.toFixed(2) + ' / hr)';
document.getElementById('tracked-ticks').textContent = ticks + ' (' + tookTime + ')';
// show shards total
document.getElementById('shards-found').textContent = formatNumber(shards.total);
// show shards per hour and total if we can
if (shards.elapsedSeconds > 0) {
const shardsPerHour = shards.total / (shards.elapsedSeconds / 3600);
const shardsPerDay = shards.total / (shards.elapsedSeconds / 86400);
document.getElementById('shard-rate').textContent = formatNumber(shardsPerHour);
document.getElementById('shard-day-rate').textContent = formatNumber(shardsPerDay);
}
}
document.getElementById('intellect-gained').textContent = stats.Intellect.gained;
document.getElementById('stamina-gained').textContent = stats.Stamina.gained;
document.getElementById('spirit-gained').textContent = stats.Spirit.gained;
document.getElementById('focus-gained').textContent = stats.Focus.gained;
document.getElementById('mana-gained').textContent = stats.Mana.gained;
if (stats["Water Mastery"].start) { document.getElementById('mastery-gained').textContent = stats["Water Mastery"].gained/100 + '%'; }
if (stats["Fire Mastery"].start) { document.getElementById('mastery-gained').textContent = stats["Fire Mastery"].gained/100 + '%'; }
if (stats["Nature Mastery"].start) { document.getElementById('mastery-gained').textContent = stats["Nature Mastery"].gained/100 + '%'; }
ticks++;
};
// parse XP text
const parseXP = (text) => {
// TODO remove the space-replacer at the end, that's only for Cyrillic users
const battleMatch = text.match(/you won, you gained ([\d,. ]+) experience/i);
if (battleMatch) return parseInt(battleMatch[1].replace(/,/g, '').replace(' ', ''));
// TODO remove the space-replacer at the end, that's only for Cyrillic users
const gatherMatch = text.match(/you went .* and gained ([\d,. ]+) experience/i);
if (gatherMatch) return parseFloat(gatherMatch[1].replace(/,/g, '').replace(' ', ''));
};
// track XP texts
const observeXP = () => {
const observer = new MutationObserver(() => {
let xpText;
if (isBattling) xpText = document.querySelector('p.text-green-400');
else if (isGathering) xpText = document.querySelector('main div div.flex.items-center');
if (xpText) {
const amount = parseXP(xpText.textContent);
if (amount) lastXP = amount;
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
// track incoming mana dust drops
const observeLoot = () => {
const observer = new MutationObserver(() => {
const lootBoxes = Array.from(document.querySelectorAll('main div div.rounded.p-3'));
lootBoxes.forEach(box => {
if (!box.textContent.includes('the following loot')) return;
const playerLootList = box.querySelector('ul');
if (!playerLootList) return;
const items = playerLootList.querySelectorAll('li');
items.forEach(li => {
if (li.textContent.includes('[Mana Dust]')) {
const span = li.querySelector('span[title]');
if (span) {
const value = parseFloat(span.title.replace(/,/g, '').replace(' ', ''));
if (!isNaN(value)) {
lastDust = value;
}
}
}
if (li.textContent.includes('[Wood]') || li.textContent.includes('[Fish]') || li.textContent.includes('[Iron]')) {
const span = li.querySelector('span[title]');
if (span) {
const value = parseFloat(span.title.replace(/,/g, '').replace(' ', ''));
if (!isNaN(value)) {
lastResource = value;
}
}
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
};
// start tracking xp and loot
observeXP();
observeLoot();
setInterval(updateTracker, 3000);
})();