Stats, Shards, XP, Dust, Quest, Res and level tracker

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

Устаревшая версия за 11.05.2025. Перейдите к последней версии.

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