// ==UserScript==
// @name Stats, Shards, XP, Dust, Quest, Res, Loot and Level tracker
// @namespace http://tampermonkey.net/
// @version 2.7.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.7.2
Stat tracker now also tracks from game memory
v2.7.1
Added more tracked items, added option to manually remove single lines if desired
v2.6.0 to v2.7.0
Completely reworking everything to read directly from the game instead of scraping HTML
Finally got rid of that horrible date parsing shit, things should be more stable from now on
v2.5.1 to v2.5.2
Started tracking ele shards in the loot tracker as well
v2.5.0
Added a tracker for some item drops
v2.4.2 all the way to 2.4.9 and with love from Germany and The Netherlands
Added even more date parsing formats, refactored the date parsing code to be more readable and its own function
v2.4.1
Added a checkbox to toggle the visibility of the stats log
Made it persist across page reloads
v2.4.0
Add a scrolling stat log right above the loot tracker
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?)
*/
"use strict";
(function() {
// 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;
const BoostTypes = Object.freeze({
BASE_SPELLPOWER: { id: 1, name: "Base Spellpower" },
BASE_WARD: { id: 2, name: "Base Ward" },
STAT_INTELLECT: { id: 3, name: "Intellect" },
STAT_STAMINA: { id: 4, name: "Stamina" },
STAT_FOCUS: { id: 5, name: "Focus" },
STAT_SPIRIT: { id: 6, name: "Spirit" },
STAT_MANA: { id: 7, name: "Mana" },
FIRE_MASTERY: { id: 10, name: "Fire Mastery" },
WATER_MASTERY: { id: 11, name: "Water Mastery" },
NATURE_MASTERY: { id: 12, name: "Nature Mastery" },
});
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" },
});
// function that formats huge numbers humanly-readable
const formatNumber = (num) => {
if (num >= 1e15) return (num / 1e15).toFixed(2) + "Qa";
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";
// if it's a result like 1.00 or 2.00 we trim the zeroes
const result = (num / 1).toFixed(2).toString();
return result.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
};
// 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`;
};
const sortByRarity = (a, b) => {
const rarityOrder = {
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
};
return rarityOrder[a.rarity] - rarityOrder[b.rarity];
};
// 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);
// keep track of found stats in a nicer format for the visible stat tracker
const statLog = [];
const cleanStats = () => ({
StartTime: null,
EndTime: new Date(),
TotalStats: 0,
TrackedStats: [
// base stats
{ definition: BoostTypes.STAT_INTELLECT, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
{ definition: BoostTypes.STAT_STAMINA, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
{ definition: BoostTypes.STAT_SPIRIT, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
{ definition: BoostTypes.STAT_FOCUS, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
{ definition: BoostTypes.STAT_MANA, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
// masteries
{ definition: BoostTypes.WATER_MASTERY, start: 0, current: 0, gained: 0, mastery: true, lastCheck: null, active: false },
{ definition: BoostTypes.FIRE_MASTERY, start: 0, current: 0, gained: 0, mastery: true, lastCheck: null, active: false },
{ definition: BoostTypes.NATURE_MASTERY, start: 0, current: 0, gained: 0, mastery: true, lastCheck: null, active: false },
],
});
// keep track of player gained stats in absolute values
let stats = cleanStats();
const getStatByName = (partialName) => {
return stats.TrackedStats.find((stat) => stat.definition.name.toLowerCase().includes(partialName.toLowerCase()));
};
// parse base stat values from game memory
const getStats = () => {
// retrieve from storage
const loadedStats = localStorage.getItem("elnaeth-stats-history");
if (loadedStats) {
const parsed = JSON.parse(loadedStats);
stats = parsed;
stats.StartTime = new Date(parsed.StartTime);
stats.EndTime = new Date(parsed.EndTime);
}
// each iteration, set the end time to right now
stats.EndTime = new Date();
const player = manarion.player;
if (!player) return stats;
// find the spell type the player is currently using and set that as active
const activeMastery = stats.TrackedStats.find((x) => x.definition.name.toLowerCase().includes(player.MagicType));
activeMastery.active = true;
const playerBoosts = manarion.player.Boosts;
if (!playerBoosts) return stats;
// loop over all keys, which are the ID of the boost
for (const boostID in playerBoosts) {
const trackedStat = stats.TrackedStats.find((x) => x.definition.id === parseInt(boostID));
if (!trackedStat) continue;
const currentBoost = playerBoosts[boostID];
// check if this is the first iteration
if (!trackedStat.lastCheck || !stats.StartTime) {
trackedStat.start = currentBoost;
trackedStat.current = currentBoost;
trackedStat.gained = 0;
} else {
// first calculate the difference, then set the new value for next iteration
const gained = currentBoost - trackedStat.current;
trackedStat.current = currentBoost;
// before updating the tracked gains, see if we gained this iteration and log it
if (gained > 0) {
trackedStat.gained += gained;
stats.TotalStats += gained;
// prepare an entry for the stat log that shows individual stat drops
const statLogEntry = {
name: trackedStat.definition.name,
timestamp: new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}),
};
// prepend this
statLog.unshift(statLogEntry);
// remove the oldest entries, keeping the log to a maximum of 150 entries
if (statLog.length > 150) {
statLog.length = 150;
}
}
}
trackedStat.lastCheck = new Date();
}
// this might be the first time we're tracking, or it has been reset manually by the player,
// so start tracking from this point onwards
if (!stats.StartTime) stats.StartTime = new Date();
// save to history
localStorage.setItem("elnaeth-stats-history", JSON.stringify(stats));
/*
// 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) {
// 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;
// keep track of the stat in the log
const statLogEntry = {
name: stat,
newCurrent: baseValue,
timestamp: new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}),
};
// try to find if this entry already exists in the log
const existingEntry = statLog.find((entry) => entry.name === stat && entry.newCurrent === baseValue);
if (!existingEntry) {
// prepend the new stat entry to the log
statLog.unshift(statLogEntry);
}
// remove the oldest entry, keeping the log to a maximum of 100 entries
if (statLog.length > 100) {
statLog.length = 100;
}
}
}
}
}
*/
return stats;
};
// we track a select set of items
const trackedItems = [
ItemTypes.MANA_DUST,
ItemTypes.CODEX,
ItemTypes.ELEMENTAL_SHARDS,
ItemTypes.WOOD,
ItemTypes.FISH,
ItemTypes.IRON,
ItemTypes.TOME_OF_FIRE,
ItemTypes.TOME_OF_WATER,
ItemTypes.TOME_OF_NATURE,
ItemTypes.TOME_OF_MANA_SHIELD,
ItemTypes.ORB_OF_CHAOS,
ItemTypes.ORB_OF_POWER,
ItemTypes.ORB_OF_DIVINITY,
ItemTypes.ENCHANT_FIRE_RESISTANCE,
ItemTypes.ENCHANT_WATER_RESISTANCE,
ItemTypes.ENCHANT_NATURE_RESISTANCE,
ItemTypes.ENCHANT_INFERNO,
ItemTypes.ENCHANT_TIDAL_WRATH,
ItemTypes.ENCHANT_WILDHEART,
ItemTypes.ENCHANT_INSIGHT,
ItemTypes.ENCHANT_BOUNTIFUL_HARVEST,
ItemTypes.ENCHANT_PROSPERITY,
ItemTypes.ENCHANT_FORTUNE,
ItemTypes.ENCHANT_GROWTH,
ItemTypes.ENCHANT_VITALITY,
ItemTypes.IRONBARK,
ItemTypes.ASBESTOS,
ItemTypes.FISH_SCALES,
ItemTypes.WATER_ESSENCE,
ItemTypes.NATURE_ESSENCE,
ItemTypes.FIRE_ESSENCE,
ItemTypes.REAGENT_ELDERWOOD,
ItemTypes.REAGENT_LODESTONE,
ItemTypes.REAGENT_WHITE_PEARL,
ItemTypes.REAGENT_FOUR_LEAF_CLOVER,
ItemTypes.REAGENT_ENCHANTED_DROPLET,
ItemTypes.REAGENT_INFERNAL_HEART,
ItemTypes.SAGEROOT,
ItemTypes.BLOOMWELL,
ItemTypes.SUNPETAL,
];
// keep track of loots
const lootOutput = {
first: new Date(),
last: new Date(),
elapsedSeconds: 0,
items: [],
};
// keep track of loot results for each gather or battle
const parseActionLoot = () => {
const stored = localStorage.getItem("elnaeth-item-drops");
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.first) parsed.first = new Date(parsed.first);
if (parsed.last) parsed.last = new Date(parsed.last);
lootOutput.first = parsed.first;
lootOutput.last = parsed.last;
lootOutput.elapsedSeconds = parsed.elapsedSeconds;
lootOutput.items = parsed.items || [];
}
// now that we know we've got either a fresh set of lootOutput, or a reloaded one from storage
// we can set the last known date to right now, since we're tracking from start to end
lootOutput.last = new Date();
if (isBattling()) {
const lastBattle = manarion.battle;
if (!lastBattle) return lootOutput;
parseLootResults(lastBattle.Loot);
// TODO also track lastBattle.Items for gear drops
}
if (isGathering()) {
const lastGather = manarion.gather;
if (!lastGather) return lootOutput;
parseLootResults(lastGather.Loot);
// TODO also track lastGather.Items for gear drops
}
// calculate elapsed time
if (lootOutput.first && lootOutput.last) {
lootOutput.elapsedSeconds = (lootOutput.last - lootOutput.first) / 1000;
}
// save to localStorage for next iteration
localStorage.setItem("elnaeth-item-drops", JSON.stringify(lootOutput));
return lootOutput;
};
const parseLootResults = (latestLoot) => {
if (!latestLoot) return;
const lootIDs = Object.keys(latestLoot);
for (const lootID of lootIDs) {
const lootDefinition = trackedItems.find((definition) => definition.id === parseInt(lootID));
if (!lootDefinition) continue; // this is not a tracked loot type
// TODO perhaps we need to parseInt() this because it is a float ?
// but we could let the number util do it instead and keep this pristine
const amount = latestLoot[lootID];
// find the item in the lootOutput.items
const existingEntry = lootOutput.items.find((entry) => entry.id === lootDefinition.id);
if (!existingEntry) {
// create new entry with this amount
lootOutput.items.push({
id: lootDefinition.id,
name: lootDefinition.name,
rarity: lootDefinition.rarity,
amount: amount,
});
} else {
// add found amount to existing entry
existingEntry.amount += amount;
}
}
};
// 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,
};
// loop through game loot tracker
for (const lootDrop of manarion.lootTracker.entries) {
// regardless of the loot, always set the first found loot to the earliest parsed loot
const date = new Date(lootDrop.Timestamp * 1000);
if (date < output.first || !output.first) {
output.first = date;
}
// skip if not a shard drop
if (lootDrop.LootID !== ItemTypes.ELEMENTAL_SHARDS.id) continue;
output.total += lootDrop.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 = () => {
if (isBattling()) {
const battleProgress = manarion.player.BattleQuestProgress;
const battleQuestGoal = manarion.player.BattleQuestCompleted;
const remaining = battleQuestGoal - battleProgress;
const seconds = remaining * 3;
return formatTime(seconds / 60);
}
if (isGathering()) {
const gatherProgress = manarion.player.GatherQuestProgress;
const gatherQuestGoal = manarion.player.GatherQuestCompleted;
const remaining = gatherQuestGoal - gatherProgress;
const seconds = remaining * 3;
return formatTime(seconds / 60);
}
// weird fallback but ok
return formatTime(0);
};
// calculate time to next level
const getTNL = () => {
if (isBattling()) {
const current = manarion.player.Experience;
const next = manarion.player.ExperienceToLevel;
return {
remaining: next - current,
remainingPercent: (((next - current) / next) * 100).toFixed(2),
};
}
if (isGathering()) {
let current;
let next;
switch (manarion.player.ActionType) {
case "mining":
current = manarion.player.MiningExperience;
next = manarion.player.MiningExperienceToLevel;
break;
case "fishing":
current = manarion.player.FishingExperience;
next = manarion.player.FishingExperienceToLevel;
break;
case "woodcutting":
current = manarion.player.WoodcuttingExperience;
next = manarion.player.WoodcuttingExperienceToLevel;
break;
}
return {
remaining: next - current,
remainingPercent: (((next - current) / next) * 100).toFixed(2),
};
}
};
const trackLastBattleGains = () => {
lastXP = 0;
lastDust = 0;
lastResource = 0;
if (!manarion.battle) return;
const lastBattle = manarion.battle;
lastXP = lastBattle.ExperienceGained ? parseInt(lastBattle.ExperienceGained) : 0;
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;
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;
}
};
const createMainTracker = (leftMenu) => {
const tracker = document.createElement("div");
tracker.innerHTML = `
<div id="elnaeth-tracker" style="display: none;"></div>
<hr width="100%" style="border-top: 1px solid var(--ring); margin: 2px 0 2px 0;" />
<div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-1 text-sm lg:grid-cols-2" title="Kindly provided by Elnaeth. Tips appreciated!">
<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>
<hr width="100%" style="border-top: 1px solid var(--ring); margin: 2px 0 2px 0;" />
<div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-1 text-sm lg:grid-cols-2" title="Kindly provided by Elnaeth. Tips appreciated!">
<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 time:</span><span id="tracked-time">Calculating...</span></div>
<div class="flex col-span-2 justify-between"><span>Total stats:</span><span id="stats-hr">Calculating...</span></div>
<div class="flex col-span-2 justify-between text-center">
<button id="reset-stat-tracker" class="m-0 p-0 bg-red-500 text-sm text-white rounded cursor-pointer">Reset</button>
</div>
</div>`;
leftMenu.insertAdjacentElement("afterend", tracker);
document.getElementById("reset-stat-tracker").addEventListener("click", () => {
// Reset the stat tracker
localStorage.removeItem("elnaeth-stats-history");
stats = cleanStats();
});
};
const createStatsTracker = (lootTracker) => {
// Create the stat tracker header
const header = document.createElement("div");
header.className = "text-center text-lg";
header.title = "Kindly provided by Elnaeth. Tips appreciated!";
// Create the checkbox to enable/disable the stats tracker
const checkboxContainer = document.createElement("div");
checkboxContainer.className = "inline-flex items-center ml-3";
const checkboxLabel = document.createElement("label");
checkboxLabel.setAttribute("for", "toggle-stats");
checkboxLabel.textContent = "Stat Drop Tracker";
checkboxContainer.appendChild(checkboxLabel);
checkboxLabel.style.marginRight = "5px";
// create the checkbox to enable/disable the items tracker
const checkbox = document.createElement("input");
// Load checkbox state from localStorage
const savedState = localStorage.getItem("elnaeth-stats-log-visible");
const isChecked = savedState === null ? true : savedState === "true";
checkbox.checked = isChecked; // set the initial checked state based on localStorage
checkbox.type = "checkbox";
checkbox.id = "toggle-stats";
checkbox.addEventListener("change", () => {
// save visibility in localstorage
localStorage.setItem("elnaeth-stats-log-visible", checkbox.checked);
statsLogContainer.style.display = checkbox.checked ? "block" : "none";
});
checkboxContainer.appendChild(checkbox);
// Append the header text and the checkbox container to the header
header.appendChild(checkboxContainer);
// add the header to the DOM, right before the loot tracker
lootTracker.previousSibling.insertAdjacentElement("beforebegin", header);
// Create the stats log container for the actual lines
const statsLogContainer = document.createElement("div");
statsLogContainer.id = "elnaeth-stats-log";
statsLogContainer.className = "scrollbar-none hover:scrollbar-thin scrollbar-track-transparent h-15 max-h-15 grow-1 overflow-x-hidden overflow-y-auto";
statsLogContainer.style.maxHeight = "200px"; // extra control for max height
statsLogContainer.style.display = checkbox.checked ? "block" : "none"; // set initial visibility based on localStorage
// add the stats log container to the DOM, right after the header
header.insertAdjacentElement("afterend", statsLogContainer);
};
const createItemsTracker = (lootTracker) => {
// Create the item drop tracker header
const header = document.createElement("div");
header.className = "text-center text-lg";
header.title = "Kindly provided by Elnaeth. Tips appreciated!";
// Create the "Stat Tracker" text and checkbox next to it
const itemCheckboxContainer = document.createElement("div");
itemCheckboxContainer.className = "inline-flex items-center ml-3";
const itemCheckboxLabel = document.createElement("label");
itemCheckboxLabel.setAttribute("for", "toggle-items");
itemCheckboxLabel.textContent = "Item Drop Tracker";
itemCheckboxContainer.appendChild(itemCheckboxLabel);
itemCheckboxLabel.style.marginRight = "5px";
// create the checkbox to enable/disable the items tracker
const itemCheckbox = document.createElement("input");
// Load checkbox state from localStorage
const itemSavedState = localStorage.getItem("elnaeth-items-log-visible");
const itemIsChecked = itemSavedState === null ? true : itemSavedState === "true";
itemCheckbox.checked = itemIsChecked; // set the initial checked state based on localStorage
itemCheckbox.type = "checkbox";
itemCheckbox.id = "toggle-items";
itemCheckbox.addEventListener("change", () => {
// save visibility in localstorage
localStorage.setItem("elnaeth-items-log-visible", itemCheckbox.checked);
itemsLogContainer.style.display = itemCheckbox.checked ? "block" : "none";
});
itemCheckboxContainer.appendChild(itemCheckbox);
// Append the header text and the checkbox container to the header
header.appendChild(itemCheckboxContainer);
const resetButton = document.createElement("button");
resetButton.textContent = "Reset";
resetButton.className = "m-0 p-0 bg-red-500 text-sm text-white rounded cursor-pointer";
resetButton.addEventListener("click", () => {
// Reset the item drops
localStorage.removeItem("elnaeth-item-drops");
lootOutput.first = new Date();
lootOutput.last = new Date();
lootOutput.elapsedSeconds = 0;
lootOutput.items = [];
});
header.appendChild(document.createElement("br"));
header.appendChild(resetButton);
// add the header to the DOM, right before the loot tracker
lootTracker.previousSibling.insertAdjacentElement("beforebegin", header);
// Create the items log container
const itemsLogContainer = document.createElement("div");
itemsLogContainer.id = "elnaeth-items-log";
itemsLogContainer.className = "scrollbar-none hover:scrollbar-thin scrollbar-track-transparent h-15 max-h-15 grow-1 overflow-x-hidden overflow-y-auto";
itemsLogContainer.style.maxHeight = "200px";
itemsLogContainer.style.display = itemCheckbox.checked ? "block" : "none";
header.insertAdjacentElement("afterend", itemsLogContainer);
};
const createTrackerContainers = () => {
// Look for the entire menu on the left of the screen
const leftMenu = document.querySelector("div.grid.grid-cols-4");
if (!leftMenu) return;
// find the native game UI loot tracker
const lootTracker = document.querySelector("div.scrollbar-none.scrollbar-track-transparent.h-60.grow-1.overflow-x-hidden.overflow-y-auto");
if (!lootTracker) return;
// add the main tracker container on the left menu
let exists = document.getElementById("elnaeth-tracker");
if (!exists) {
createMainTracker(leftMenu);
}
// create our stats tracker window above the loot tracker
exists = document.getElementById("elnaeth-stats-log");
if (!exists) {
createStatsTracker(lootTracker);
}
// create our items tracker window above the loot tracker
exists = document.getElementById("elnaeth-items-log");
if (!exists) {
createItemsTracker(lootTracker);
document.getElementById("elnaeth-items-log").addEventListener("click", function (event) {
if (event.target.matches("i.elnaeth-delete-row")) {
const itemId = parseInt(event.target.dataset.itemId);
const itemDrops = JSON.parse(localStorage.getItem("elnaeth-item-drops"));
itemDrops.items = itemDrops.items.filter((i) => i.id !== itemId);
localStorage.setItem("elnaeth-item-drops", JSON.stringify(itemDrops));
}
});
}
};
const updateTracker = () => {
// create the tracker HTML elements
createTrackerContainers();
// 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";
}
// keep track of stats
const stats = getStats();
const logTracker = document.getElementById("elnaeth-stats-log");
if (logTracker) {
// clear the log tracker and re-render
logTracker.innerHTML = "";
statLog.forEach((stat) => {
const statDiv = document.createElement("div");
statDiv.innerHTML = `
<div class="max-w-full overflow-hidden align-middle text-sm text-ellipsis whitespace-nowrap">
<span class="text-foreground/50 text-xs"> ${stat.timestamp} </span>
<span class="rarity-uncommon"> +1 ${stat.name} </span>
</div>`;
logTracker.appendChild(statDiv);
});
}
// keep track of gains, like exp and dust, or incoming resources
if (isBattling()) trackLastBattleGains();
if (isGathering()) trackLastGatheringGains();
// 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;
// Calculate time to next level
const tnl = getTNL();
const minutesToLevel = xpPerHr > 0 && tnl ? (tnl.remaining / xpPerHr) * 60 : null;
// Calculate quest completion time
const questTime = getQuestTime();
// Calculate percentage to next level
let percentageToLevel = "N/A";
if (tnl && xpPerHr > 0) {
percentageToLevel = tnl.remainingPercent + "%";
}
// 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...";
// calculate stats per hour
if (ticks > 0) {
const secondsPassed = (stats.EndTime - stats.StartTime) / 1000;
const hoursPassed = secondsPassed / 3600;
const statsPerHour = stats.TotalStats / hoursPassed;
const tookTime = formatTime(secondsPassed / 60);
// show stats per hour and total
document.getElementById("stats-hr").textContent = stats.TotalStats + " (" + statsPerHour.toFixed(2) + " / hr)";
document.getElementById("tracked-time").textContent = tookTime;
// show shards per hour and total if we can
const shards = parseShards();
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);
}
}
// write out stat gains
document.getElementById("intellect-gained").textContent = getStatByName("intellect").gained;
document.getElementById("stamina-gained").textContent = getStatByName("stamina").gained;
document.getElementById("spirit-gained").textContent = getStatByName("spirit").gained;
document.getElementById("focus-gained").textContent = getStatByName("focus").gained;
document.getElementById("mana-gained").textContent = getStatByName("mana").gained;
// write out current mastery gains
if (getStatByName("fire").active) document.getElementById("mastery-gained").textContent = getStatByName("fire").gained / 100 + "%";
if (getStatByName("water").active) document.getElementById("mastery-gained").textContent = getStatByName("water").gained / 100 + "%";
if (getStatByName("nature").active) document.getElementById("mastery-gained").textContent = getStatByName("nature").gained / 100 + "%";
// parse the latest action for any loot drops
const itemDrops = parseActionLoot();
// Render item drops in the item tracker container
const itemsLog = document.getElementById("elnaeth-items-log");
if (itemsLog) {
itemsLog.innerHTML = "";
// Calculate elapsed time for per-hour rate
const elapsedHours = itemDrops.elapsedSeconds > 0 ? itemDrops.elapsedSeconds / 3600 : 1;
// render the items
const sortedByRarity = Array.from(itemDrops.items).sort((a, b) => sortByRarity(a, b));
sortedByRarity.forEach((item) => {
const perHour = (item.amount / elapsedHours).toFixed(2);
const itemDiv = document.createElement("div");
itemDiv.innerHTML = `
<div class="max-w-full overflow-hidden align-middle text-sm text-ellipsis whitespace-nowrap">
<i data-item-id="${item.id}" class="elnaeth-delete-row cursor-pointer text-xs">[-]</i>
<span class="rarity-${item.rarity}">${item.name}:</span> <span>${formatNumber(item.amount)}</span>
<span class="text-foreground/50 text-xs">(${formatNumber(perHour)}/HR)</span>
</div>`;
itemsLog.appendChild(itemDiv);
});
}
ticks++;
};
const condenseMainMenu = () => {
document.querySelectorAll("div.grid.grid-cols-4").forEach((grid) => {
if (grid.classList.contains("gap-y-2")) {
grid.classList.remove("gap-y-2");
}
});
};
// start tracking everything when the DOM is done
setTimeout(() => {
// injects our containers and pulls fresh data
updateTracker();
// make the left menu not use as much vertical spacing
condenseMainMenu();
}, 250);
// Update the tracker every 3 seconds
setInterval(updateTracker, 3000);
})();