// ==UserScript==
// @name Manarion
// @namespace http://tampermonkey.net/
// @version 2025-05-30
// @description Displays calculated values for XP, Dust, Shards, and Quest progress in Manarion game
// @license MIT
// @author Rook
// @match *://manarion.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=manarion.com
// @grant none
// ==/UserScript==
/*
======= Changelog =======
v2025-05-30
Reworked many things to read from game instead of scraping HTML, patterned after Elnaeth's work:
https://greasyforks.org/en/scripts/535505-stats-shards-xp-dust-quest-res-loot-and-level-tracker
Will continue to improve this script, but it is now functional again and should avoid breaking with further UI updates.
*/
"use strict";
(function() {
//'use strict';
const debug = false;
const actionsPerMin = 20;//Every 3 seconds
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true
});
let grid = document.querySelector('#root > div > div.flex > div.border-primary > div.grid');
// 1. Define ItemTypes for easy reference
const ItemTypes = Object.freeze({
MANA_DUST: { id: 1, name: "Mana Dust", rarity: "common" },
ELEMENTAL_SHARDS: { id: 2, name: "Elemental Shards", rarity: "common" },
CODEX: { id: 3, name: "Codex", rarity: "epic" },
FIRE_ESSENCE: { id: 4, name: "Fire Essence", rarity: "rare" },
WATER_ESSENCE: { id: 5, name: "Water Essence", rarity: "rare" },
NATURE_ESSENCE: { id: 6, name: "Nature Essence", rarity: "rare" },
FISH: { id: 7, name: "Fish", rarity: "common" },
WOOD: { id: 8, name: "Wood", rarity: "common" },
IRON: { id: 9, name: "Iron", rarity: "common" },
ASBESTOS: { id: 10, name: "Asbestos", rarity: "uncommon" },
IRONBARK: { id: 11, name: "Ironbark", rarity: "uncommon" },
FISH_SCALES: { id: 12, name: "Fish Scales", rarity: "uncommon" },
TOME_OF_FIRE: { id: 13, name: "Tome of Fire", rarity: "uncommon" },
TOME_OF_WATER: { id: 14, name: "Tome of Water", rarity: "uncommon" },
TOME_OF_NATURE: { id: 15, name: "Tome of Nature", rarity: "uncommon" },
TOME_OF_MANA_SHIELD: { id: 16, name: "Tome of Mana Shield", rarity: "epic" },
ENCHANT_FIRE_RESISTANCE: { id: 17, name: "Formula: Fire Resistance", rarity: "epic" },
ENCHANT_WATER_RESISTANCE: { id: 18, name: "Formula: Water Resistance", rarity: "epic" },
ENCHANT_NATURE_RESISTANCE: { id: 19, name: "Formula: Nature Resistance", rarity: "epic" },
ENCHANT_INFERNO: { id: 20, name: "Formula: Inferno", rarity: "epic" },
ENCHANT_TIDAL_WRATH: { id: 21, name: "Formula: Tidal Wrath", rarity: "epic" },
ENCHANT_WILDHEART: { id: 22, name: "Formula: Wildheart", rarity: "epic" },
ENCHANT_INSIGHT: { id: 23, name: "Formula: Insight", rarity: "epic" },
ENCHANT_BOUNTIFUL_HARVEST: { id: 24, name: "Formula: Bountiful Harvest", rarity: "epic" },
ENCHANT_PROSPERITY: { id: 25, name: "Formula: Prosperity", rarity: "epic" },
ENCHANT_FORTUNE: { id: 26, name: "Formula: Fortune", rarity: "epic" },
ENCHANT_GROWTH: { id: 27, name: "Formula: Growth", rarity: "epic" },
ENCHANT_VITALITY: { id: 28, name: "Formula: Vitality", rarity: "epic" },
REAGENT_ELDERWOOD: { id: 29, name: "Elderwood", rarity: "uncommon" },
REAGENT_LODESTONE: { id: 30, name: "Lodestone", rarity: "uncommon" },
REAGENT_WHITE_PEARL: { id: 31, name: "White Pearl", rarity: "uncommon" },
REAGENT_FOUR_LEAF_CLOVER: { id: 32, name: "Four Leaf Clover", rarity: "uncommon" },
REAGENT_ENCHANTED_DROPLET: { id: 33, name: "Enchanted Droplet", rarity: "uncommon" },
REAGENT_INFERNAL_HEART: { id: 34, name: "Infernal Heart", rarity: "uncommon" },
ORB_OF_POWER: { id: 35, name: "Orb of Power", rarity: "rare" },
ORB_OF_CHAOS: { id: 36, name: "Orb of Chaos", rarity: "epic" },
ORB_OF_DIVINITY: { id: 37, name: "Orb of Divinity", rarity: "legendary" },
SUNPETAL: { id: 39, name: "Sunpetal", rarity: "rare" },
SAGEROOT: { id: 40, name: "Sageroot", rarity: "common" },
BLOOMWELL: { id: 41, name: "Bloomwell", rarity: "common" },
});
//let manaDustGain = 0;
let manaDustForSpellpower = 0;
let manaDustForWard = 0;
const STORAGE_KEY = 'manarionShardTrackerData';
const MAX_LOOT_ENTRIES = 100;
// Load from localStorage
let stored = localStorage.getItem(STORAGE_KEY);
let shardDrops = stored ? JSON.parse(stored) : [];
let seenEntries = new Set(shardDrops.map(e => `${e.timestamp}|${e.shardAmount}`));
let lootDropCount = 0;
//let xpGain, lastXP = 0;
let levelsPerHour = 0;
let minsToLevel = 0;
let questActionsRemaining = -1;
// 2. Centralized gain tracking
let lastXP = 0, lastDust = 0, lastResource = 0;
let xpGain = 0, manaDustGain = 0, resourceGain = 0;
addEventListener("load", main)
setTimeout(main, 5000)
function main() {
if (!manarion || !manarion.player) return false;
grid = document.querySelector('#root > div > div.flex > div.border-primary > div.grid');
if (isBattling()) trackLastBattleGains();
if (isGathering()) trackLastGatheringGains();
perHour("XP",xpGain);
perHour("Levels",levelsPerHour, "", false);
perHour("Dust",manaDustGain);
perHour("Shards", shardsPerHour(), "", false);
etaUntil("Level", Math.round(minsToLevel));
// 4. Main tick/update loop
setInterval(mainTick, 3000);
}
// keep track of what kind of thing we're doing right now
const isBattling = () => manarion.player.ActionType === "battle";
const isGathering = () => ["mining", "fishing", "woodcutting"].includes(manarion.player.ActionType);
const trackLastBattleGains = () => {
lastXP = 0;
lastDust = 0;
lastResource = 0;
if (!manarion.battle) return;
const lastBattle = manarion.battle;
xpGain = lastXP = lastBattle.ExperienceGained ? parseInt(lastBattle.ExperienceGained) : 0;
manaDustGain = lastDust = lastBattle.Loot ? parseInt(lastBattle.Loot[ItemTypes.MANA_DUST.id]) : 0;
};
const trackLastGatheringGains = () => {
lastXP = 0;
lastResource = 0;
lastDust = 0;
if (!manarion.gather) return;
const lastGather = manarion.gather;
xpGain = lastXP = lastGather.ExperienceGained ? parseFloat(lastGather.ExperienceGained) : 0;
switch (manarion.player.ActionType) {
case "mining":
lastResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.IRON.id]) : 0;
break;
case "fishing":
lastResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.FISH.id]) : 0;
break;
case "woodcutting":
lastResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.WOOD.id]) : 0;
break;
}
};
function timeToQuest(){
/*const questElement = document.querySelector("#root > div > div.flex.max-w-screen > div.border-primary.w-full > div.grid.grid-cols-4 > div:nth-child(2) > div > p")
//document.querySelector("#root > div > div.flex > div.border-primary > div.grid.grid-cols-4 > div:nth-child(1) > div > p")
const questText = questElement.textContent;//"Defeat 21/333 enemies."
const questNums = questText.match(/\d+/g);
const questProgress = questNums[0];
const questGoal = questNums[1];*/
questActionsRemaining = getQuestActionsRemaining();
const minsToQuest = Math.round(questActionsRemaining / actionsPerMin, 1);
etaUntil("Quest", minsToQuest);
}
function shardsPerHour() {
if (shardDrops.length < 2) {
return 0; // Not enough data to calculate rate
}
const times = shardDrops.map(d => new Date(d.timestamp));
const firstTime = times[0];
const lastTime = times[times.length - 1];
const totalTimeMs = lastTime - firstTime;
if (totalTimeMs <= 0) return 0;
const totalShards = shardDrops.reduce((sum, drop) => sum + drop.shardAmount, 0);
const avgShards = totalShards / shardDrops.length;
const totalTimeHours = totalTimeMs / (1000 * 60 * 60);
const shardsPerHour = totalShards / totalTimeHours;
if(debug){
console.log(`Total Shards: ${totalShards}`);
console.log(`Average Shards per Drop: ${avgShards.toFixed(2)}`);
console.log(`Duration: ${totalTimeHours.toFixed(2)} hours`);
console.log(`Shards per Hour: ${shardsPerHour.toFixed(2)}`);
}
return shardsPerHour;
}
function resourceGathering(){
const ul = document.querySelector("#root > div > div.flex > main > div > div.mt-4 > ul");
if(!ul){
console.log("ul = ", ul);
return;
}
const liElements = ul.querySelectorAll('li');
let results = [];
liElements.forEach(li => {
const spanWithTitle = li.querySelector('span[title]');
const resourceSpan = li.querySelector('span.rarity-common');
if (!spanWithTitle || !resourceSpan) return;
const title = spanWithTitle.getAttribute('title');
const gain = parseFloat(title);
// Extract resource name from text content: "[Iron]" → "Iron"
const resourceText = resourceSpan.textContent.trim();
const nameMatch = resourceText.match(/\[(.*?)\]/);
const name = nameMatch ? nameMatch[1] : null;
if (name && !isNaN(gain)) {
results.push({ resourceName: name, resourceGain: gain });
}
let resourceName = results[0].resourceName;
let resourceGain = results[0].resourceGain;
let resourceOptions = ["Iron","Fish","Wood"];
results.forEach(result => {
if(resourceOptions.includes(result.resourceName)){
resourceName = result.resourceName;
resourceGain = result.resourceGain;
perHour(resourceName, resourceGain, "Resources");
return;
}
});
});
}
function scanLootTracker() {
const lootTrackerHeader = Array.from(document.querySelectorAll('div.relative.mb-1.text-center.text-lg'))
.find(div => div.textContent.includes('Loot Tracker'));
if (!lootTrackerHeader) return;
const lootContainer = lootTrackerHeader.nextElementSibling;
if (!lootContainer) return;
const allLootRows = Array.from(lootContainer.children);
const totalEntries = allLootRows.length;
const lootEntries = lootContainer.querySelectorAll('div.rarity-common');
let elementalShardCount = 0;
let newDataAdded = false;
lootEntries.forEach(entry => {
if (!entry.textContent.includes('[Elemental Shards]')) return;
elementalShardCount++;
const spans = entry.querySelectorAll('span[title]');
let timestamp = null;
let shardAmount = null;
spans.forEach(span => {
const title = span.getAttribute('title');
if (/\d{1,2}:\d{2}:\d{2}/.test(span.textContent)) {
timestamp = span.textContent.trim();
} else if (/[\d,]+/.test(title)) {
shardAmount = parseInt(title.replace(/,/g, ''), 10);
}
});
if (timestamp && shardAmount !== null) {
const dateObj = parseTimeString(timestamp);
const iso = dateObj.toISOString();
const key = `${iso}|${shardAmount}`;
if (!seenEntries.has(key)) {
seenEntries.add(key);
shardDrops.push({ timestamp: iso, shardAmount });
newDataAdded = true;
}
}
});
// 🧹 Remove legacy-format entries (non-ISO timestamps)
const cleaned = shardDrops.filter(entry => {
const isValid = typeof entry.timestamp === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(entry.timestamp);
if (!isValid) seenEntries.delete(`${entry.timestamp}|${entry.shardAmount}`);
return isValid;
});
if (cleaned.length !== shardDrops.length) {
shardDrops.length = 0;
shardDrops.push(...cleaned);
newDataAdded = true;
}
// 🔃 Sort by timestamp (oldest first)
shardDrops.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
// ✂ Trim to MAX_ENTRIES
if (shardDrops.length > MAX_LOOT_ENTRIES) {
const removed = shardDrops.splice(0, shardDrops.length - MAX_LOOT_ENTRIES);
removed.forEach(e => seenEntries.delete(`${e.timestamp}|${e.shardAmount}`));
newDataAdded = true;
}
const shardRatio = (elementalShardCount / totalEntries * 100).toFixed(2);
if (newDataAdded) {
saveToLocalStorage();
console.log(`Shards: ${elementalShardCount}, Total: ${totalEntries}, Ratio: ${shardRatio}%`);
perHour("Shards", shardsPerHour(), "", false);
}
}
function saveToLocalStorage() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(shardDrops));
}
function onHarvestPage(){
const harvestDiv = document.querySelector("#root > div > div.flex > main > div > div:nth-child(1)");
if(!harvestDiv){return false;}
const text = harvestDiv.textContent.trim();
const regex = /You went (woodcutting|mining|fishing) and gained .* experience/i;
return regex.test(text);
}
function timeToLevel() {
//const xpGainTextElementBattle = document.querySelector("#root > div > div.flex.max-w-screen > main > div > div:nth-child(3) > p.text-green-400")
//const xpGainTextElementHarvest = document.querySelector("#root > div > div.flex > main > div > div:nth-child(1) span[title]");
//let xpGainText = "";
let xp;
let xpGoal;
if(isBattling()){
xp = manarion.player.Experience;
xpGoal = manarion.player.ExperienceToLevel;
//manaDustGain = detectFloat(document.querySelector('#root > div > div.flex > main > div > div:nth-child(4) > ul > li > span:nth-child(1)').title);
//xpGainText = xpGainTextElementBattle.textContent;
//console.log(xpGainText);
//xpGain = detectInt(xpGainText);
}
else if(isGathering()){
resourceGathering();
switch (manarion.player.ActionType) {
case "mining":
xp = manarion.player.MiningExperience;
xpGoal = manarion.player.MiningExperienceToLevel;
break;
case "fishing":
xp = manarion.player.FishingExperience;
xpGoal = manarion.player.FishingExperienceToLevel;
break;
case "woodcutting":
xp = manarion.player.WoodcuttingExperience;
xpGoal = manarion.player.WoodcuttingExperienceToLevel;
break;
}
//xpGainText = xpGainTextElementHarvest.title;
//xpGain = detectFloat(xpGainText);
}
//const xp = parseInt(document.querySelector('#root > div > div.flex > div.border-primary > div.grid > div:nth-child(5) > span.break-all > span:nth-child(1)').title.replace(/,/g, ""));
//const xpGoal = parseInt(document.querySelector('#root > div > div.flex > div.border-primary > div.grid > div:nth-child(5) > span.break-all > span:nth-child(2)').title.replace(/,/g, ""));
const xpDiff = xpGoal - xp;
const xpPerMinute = xpGain * actionsPerMin;
const xpPerHour = xpGain * actionsPerMin * 60;
if (!xpGain || xpPerMinute === 0) {
console.warn("xpGain is zero or falsy, can't compute time.");
minsToLevel = Infinity;
levelsPerHour = 0;
} else {
minsToLevel = xpDiff / xpPerMinute;
levelsPerHour = xpPerHour / xpGoal;
}
if(debug){
//console.log(xpGainText);
console.log("XpGain: " + xpGain);
console.log("Current XP: " + xp);
console.log("XP Goal: " + xpGoal);
console.log("XP Diff: " + xpDiff);
console.log("MinsToLevel: " + minsToLevel);
console.log("ManaDustGain: " + manaDustGain);
console.log("minsToEntireLevel: " + minsToEntireLevel);
console.log("levelsPerHour: " + levelsPerHour);
}
perHour("XP",xpGain);
perHour("Levels",levelsPerHour, "", false);
perHour("Dust",manaDustGain);
perHour("Shards", shardsPerHour(), "", false);
etaUntil("Level", Math.round(minsToLevel));
}
function research(){
const onResearchPage = document.querySelector('div.space-y-5');
if(onResearchPage){
if(debug){
console.log("On the Research page");
}
const manaDust = parseInt(document.querySelector("#root > div > div.flex > div.border-primary > div.grid > div:nth-child(6) > span:nth-child(2) > span").title.replace(/,/g, ""));
const manaDustForSpellpowerElement = document.querySelector("div.space-y-5 > div:nth-child(1) > div:nth-child(2) > div:nth-child(2) > div:nth-child(1) > div:nth-child(4) > span:nth-child(1)");
if(manaDustForSpellpowerElement){
if(debug){
console.log("Spellpower");
}
manaDustForSpellpower = parseInt(manaDustForSpellpowerElement.title.replace(/,/g, ""));
manaDustForSpellpower -= manaDust;
const minsToSpellpower = Math.round(manaDustForSpellpower / (actionsPerMin * manaDustGain), 1);
etaUntil("Spellpower", minsToSpellpower);
}
const manaDustForWardElement = document.querySelector("div.space-y-5 > div:nth-child(1) > div:nth-child(2) > div:nth-child(2) > div:nth-child(2) > div:nth-child(4) > span:nth-child(1)");
if(manaDustForWardElement){
if(debug){
console.log("Ward");
}
manaDustForWard = parseInt(manaDustForWardElement.title.replace(/,/g, ""));
manaDustForWard -= manaDust;
const minsToWard = Math.round(manaDustForWard / (actionsPerMin * manaDustGain),1);
etaUntil("Ward", minsToWard);
}
}
}
function updateTitle(){
if(questActionsRemaining<0){return;}
let docTitle = document.title;
const endIndex = indexOfEndOfWord(docTitle, "Manarion");
docTitle = trimStringAtIndex(docTitle, endIndex);
if(questActionsRemaining==0){
docTitle += " | QUEST";
}
else{
docTitle += " | " + questActionsRemaining;
}
document.title = docTitle;
}
function indexOfEndOfWord(text, word) {
const index = text.indexOf(word);
if (index === -1) {
return -1;
}
return index + word.length;
}
function trimStringAtIndex(str, index) {
if (index < 0 || index >= str.length) {
return str;
}
return str.slice(0, index);
}
function insertString(originalString, stringToInsert, index) {
if (index < 0 || index > originalString.length) {
return "Index is out of bounds";
}
return originalString.slice(0, index) + stringToInsert + originalString.slice(index);
}
function perHour(name, value, alternateId = "", doCalc = true){
if(doCalc)
{
value = formatNumberWithCommas(value * actionsPerMin * 60);
}
if(typeof value === 'number')
{
value = value.toFixed(2);
}
addGridRow(name + "/Hour", value, alternateId);
}
function etaUntil(event, minutes){
let eta = "";
if (!isFinite(minutes)) {
eta = "Unknown";
}
else if(minutes > 0){
eta = "(" + etaPhrase(minutes) + ") " + timeStamp(timePlusMinutes(minutes));
}
else if (minutes == 0)
{
eta = "<1m";
}
else{
eta = "Ready";
}
addGridRow("Next " + event, eta);
}
function etaPhrase(minutes){
let min = minutes;
let days = Math.floor(min / (60 * 24));
min = min - (days * (60 * 24));
let hours = Math.floor((min / 60));
min = min - (hours * 60);
let result = "";
if (days>0)
{
result += days + "d:";
}
if (hours>0)
{
result += hours + "h:";
}
if (min>0)
{
result += min + "m";
}
return result;
}
function timePlusMinutes(minutesToAdd) {
minutesToAdd = Number(minutesToAdd);
if (!isFinite(minutesToAdd)) {
console.warn("minutesToAdd is not a valid finite number:", minutesToAdd);
return new Date(NaN);
}
const now = new Date();
const msToAdd = minutesToAdd * 60 * 1000;
const newTime = new Date(now.getTime() + msToAdd);
return newTime;
}
function timeStamp(time){
try {
return timeFormatter.format(time);
} catch (error) {
if (error instanceof RangeError) {
// Handle the RangeError specifically
console.error("RangeError caught for time: " + time + ":", error.message);
} else {
// Handle other types of errors, or re-throw if necessary
console.error("An unexpected error occurred:", error);
}
}
}
function parseTimeString(t) {
const [h, m, s] = t.split(':').map(Number);
const now = new Date();
now.setHours(h, m, s, 0);
return new Date(now); // Defensive copy
}
function formatNumberWithCommas(number) {
return number.toLocaleString('en-US');
}
function detectInt(str) {
const regex = /\d+/;
const match = str.replace(/,/g, "").match(regex);
return match ? parseInt(match[0], 10) : null;
}
function detectFloat(str) {
const regex = /[+-]?\d+(\.\d+)?/; // Match integers or decimals, optionally signed
const match = str.replace(/,/g, "").match(regex);
return match ? parseFloat(match[0]) : null;
}
function addGridRow(label, value, alternateId = "") {
let oldDiv = document.getElementById(label);
let hasAlternateId = false;
let spanId = label.toLowerCase().replace(" ", "-");
let labelSpan = undefined;
if(alternateId.length > 0){
hasAlternateId = true;
spanId = alternateId.toLowerCase().replace(" ", "-");
oldDiv = document.getElementById(alternateId);
}
if(oldDiv){
//If it already exists, just update the existing content
labelSpan = oldDiv.querySelector("span:nth-child(1)");
labelSpan.textContent = label;
const span = document.getElementById(spanId);
span.textContent = value;
span.title = value;
return;
}
const newDiv = document.createElement('div');
if(hasAlternateId){
newDiv.setAttribute('id', alternateId);
}
else
{
newDiv.setAttribute('id', label);
}
newDiv.classList.add("col-span-2", "flex", "justify-between");
labelSpan = document.createElement('span');
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.setAttribute('id', spanId);
valueSpan.textContent = value;
valueSpan.title = value;
const wrapper = document.createElement('span');
wrapper.appendChild(valueSpan);
newDiv.appendChild(labelSpan);
newDiv.appendChild(wrapper);
grid.appendChild(newDiv);
}
// 3. Use manarion for quest progress
function getQuestActionsRemaining() {
if (!manarion || !manarion.player) return -1;
if (manarion.player.ActionType === "battle") {
return manarion.player.BattleQuestCompleted - manarion.player.BattleQuestProgress;
} else if (["mining", "fishing", "woodcutting"].includes(manarion.player.ActionType)) {
return manarion.player.GatherQuestCompleted - manarion.player.GatherQuestProgress;
}
return -1;
}
// 4. Main tick/update loop
function mainTick() {
if (isBattling()) {
trackLastBattleGains();
} else if (isGathering()) {
trackLastGatheringGains();
}
// XP/Hour, Dust/Hour, Resource/Hour
perHour("XP", xpGain);
perHour("Dust", Math.round(manaDustGain));
// Quest ETA
timeToLevel();
//research();
resourceGathering();
//scanLootTracker();
timeToQuest();
}
setInterval(mainTick, 3000);
})();