Elethor General Purpose

Provides some general additions to Elethor

// ==UserScript==
// @name         Elethor General Purpose
// @description  Provides some general additions to Elethor
// @namespace    https://www.elethor.com/
// @version      1.7.17
// @author       Xortrox
// @contributor  Kidel
// @contributor  Saya
// @contributor  Archeron
// @contributor  Hito
// @match        https://elethor.com/*
// @match        https://www.elethor.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function() {
    const currentUserData = {};

    const moduleName = 'Elethor General Purpose';
    const version = '1.7.9';

    const profileURL = '/profile/';

    function initializeXHRHook() {
        let rawSend = XMLHttpRequest.prototype.send;

        XMLHttpRequest.prototype.send = function() {
            if (!this._hooked) {
                this._hooked = true;

                this.addEventListener('readystatechange', function() {
                    if (this.readyState === XMLHttpRequest.DONE) {
                        setupHook(this);
                    }
                }, false);
            }
            rawSend.apply(this, arguments);
        }

        function setupHook(xhr) {
            if (window.elethorGeneralPurposeOnXHR) {
                const e = new Event('EGPXHR');
                e.xhr = xhr;

                window.elethorGeneralPurposeOnXHR.dispatchEvent(e);
            }
        }
        window.elethorGeneralPurposeOnXHR = new EventTarget();

        console.log(`[${moduleName} v${version}] XHR Hook initialized.`);
    }

    function initializeUserLoadListener() {
        elethorGeneralPurposeOnXHR.addEventListener('EGPXHR', function (e) {
            console.log('user load?:', e?.xhr?.responseURL);
            if (e && e.xhr
                && e.xhr.responseURL
                && e.xhr.responseURL.endsWith
                && e.xhr.responseURL.endsWith('/game/user')
            ) {
                try {
                    const userData = JSON.parse(e.xhr.responseText);

                    if (userData) {
                        for (const key of Object.keys(userData)) {
                            currentUserData[key] = userData[key];
                        }
                    }
                } catch (e) {
                    console.log(`[${moduleName} v${version}] Error parsing userData:`, e);
                }

            }
        });

        console.log(`[${moduleName} v${version}] User Load Listener initialized.`);
    }

    function initializeToastKiller() {
        document.addEventListener('click', function(e) {
            if (e.target
                && e.target.className
                && e.target.className.includes
                && e.target.className.includes('toasted toasted-primary')
            ) {
                e.target.remove();
            }
        });

        console.log(`[${moduleName} v${version}] Toast Killer initialized.`);
    }

    async function forceLoadUser() {
        const userLoad = await getUserSelf();
    }

    function initializeInventoryStatsLoadListener() {
        elethorGeneralPurposeOnXHR.addEventListener('EGPXHR', function (e) {
            if (e && e.xhr
                && e.xhr.responseURL
                && e.xhr.responseURL.endsWith
                && e.xhr.responseURL.endsWith('/game/inventory/stats')
            ) {
                setTimeout(async () => {
                    if (Object.keys(currentUserData).length === 0) {
                        await forceLoadUser();
                    }

                    updateEquipmentPercentageSummary();

                    setTimeout(updateInventoryStatsPercentages, 1000);
                });
            }
        });

        console.log(`[${moduleName} v${version}] Inventory Stats Load Listener initialized.`);
    }

    function updateEquipmentPercentageSummary() {
        document.querySelector('.contains-equipment>div>div:nth-child(1)').setAttribute('style', 'width: 50%');
        document.querySelector('.contains-equipment>div>div:nth-child(2)').setAttribute('style', 'width: 25%');
        let percentagesTable = document.querySelector('#egpPercentagesSummary')
        if (!percentagesTable){
            percentagesTable = document.querySelector('.contains-equipment>div>div:nth-child(2)').cloneNode(true);
            percentagesTable.setAttribute('style', 'width: 25%');
            percentagesTable.id='egpPercentagesSummary';
            document.querySelector('.contains-equipment>div').appendChild(percentagesTable);

            for (const child of percentagesTable.children[0].children) {
                if (child && child.children && child.children[0]) {
                    child.children[0].remove();
                }
            }

            document.querySelector('#egpPercentagesSummary>table>tr:nth-child(8)').setAttribute('style', 'height:43px');
        }
    }

    function getStatSummary(equipment) {
        const summary = {
            base: {},
            energizements: {}
        };

        if (equipment) {
            for (const key of Object.keys(equipment)) {
                const item = equipment[key];

                /**
                 * Sums base attributes by name
                 * */
                if (item && item.attributes) {
                    for (const attributeName of Object.keys(item.attributes)) {
                        const attributeValue = item.attributes[attributeName];

                        if (!summary.base[attributeName]) {
                            summary.base[attributeName] = 0;
                        }

                        summary.base[attributeName] += attributeValue;
                    }
                }

                /**
                 * Sums energizements by stat name
                 * */
                if (item && item.upgrade && item.upgrade.energizements) {
                    for (const energizement of item.upgrade.energizements) {
                        if (!summary.energizements[energizement.stat]) {
                            summary.energizements[energizement.stat] = 0;
                        }

                        summary.energizements[energizement.stat] += Number(energizement.boost);
                    }
                }
            }
        }

        return summary;
    }

    function updateInventoryStatsPercentages() {
        let percentagesTable = document.querySelector('#egpPercentagesSummary')
        if (percentagesTable && currentUserData && currentUserData.equipment){
            const statSummary = getStatSummary(currentUserData.equipment);

            const baseKeys = Object.keys(statSummary.base);
            const energizementKeys = Object.keys(statSummary.energizements);

            let allKeys = baseKeys.concat(energizementKeys);
            const filterUniques = {};
            for (const key of allKeys){
                filterUniques[key] = true;
            }
            allKeys = Object.keys(filterUniques);
            allKeys.sort();

            allKeys.push('actions');

            const tableRows = percentagesTable.children[0].children;

            for(const row of tableRows) {
                if (row
                    && row.children
                    && row.children[0]
                    && row.children[0].children[0]
                ) {
                    const rowText = row.children[0].children[0];
                    rowText.innerText = '';
                }
            }

            let rowIndex = 0;
            for (const key of allKeys) {
                if (key === 'puncture') {
                    continue;
                }

                const row = tableRows[rowIndex];
                if (row
                    && row.children
                    && row.children[0]
                    && row.children[0].children[0]
                ) {
                    const rowText = row.children[0].children[0];

                    const rowBase = statSummary.base[key] || 0;
                    const rowEnergizement = (statSummary.energizements[key] || 0);
                    const rowEnergizementPercentage = (statSummary.energizements[key] || 0) * 100;

                    if (key.startsWith('+')) {
                        rowText.innerText = `${key} per 10 levels: ${rowEnergizement}`;
                    } else if (key === 'actions') {
                        const actions = currentUserData.user.bonus_actions || 0;
                        rowText.innerText = `Bonus Actions: ${actions}`;
                    } else {
                        rowText.innerText = `${key}: ${rowBase} (${rowEnergizementPercentage.toFixed(0)}%)`;
                    }

                    rowIndex++;
                }
            }
        }
    }

    function initializeLocationChangeListener() {
        let previousLocation = window.location.href;

        window.elethorGeneralPurposeOnLocationChange = new EventTarget();

        window.elethorLocationInterval = setInterval(() => {
            if (previousLocation !== window.location.href) {
                previousLocation = window.location.href;

                const e = new Event('EGPLocation');
                e.newLocation = window.location.href;
                window.elethorGeneralPurposeOnLocationChange.dispatchEvent(e);
            }

        }, 500);

        console.log(`[${moduleName} v${version}] Location Change Listener initialized.`);
    }

    function getProfileCombatElement() {
        const skillElements = document.querySelectorAll('.is-round-skill .progressbar-text>div>p:first-child');
        const skillElements2 = document.querySelectorAll('.is-round-skill .progressbar-text>div>p:nth-child(2)');

        let index = 0;
        for (const skillElement of skillElements2) {
            if (skillElement.innerText?.toLowerCase().includes('combat')) {
                return skillElements[index].parentElement?.parentElement?.parentElement?.parentElement?.parentElement;
            }
            index++;
        }
    }

    function getProfileMiningElement() {
        const skillElements = document.querySelectorAll('.is-round-skill .progressbar-text>div>p:first-child');
        const skillElements2 = document.querySelectorAll('.is-round-skill .progressbar-text>div>p:nth-child(2)');

        let index = 0;
        for (const skillElement of skillElements2) {
            if (skillElement.innerText?.toLowerCase().includes('mining')) {
                return skillElements[index].parentElement?.parentElement?.parentElement?.parentElement?.parentElement;
            }
            index++;
        }
    }

    function updateXPTracker(difference) {
        const combatElement = getProfileCombatElement();
        if (combatElement) {
            if (difference.combat > 0) {
                combatElement.setAttribute('data-combat-experience-ahead', `(+${formatNormalNumber(difference.combat)})`);
                combatElement.setAttribute('style', `color:lime`);
            } else {
                combatElement.setAttribute('data-combat-experience-ahead', `(${formatNormalNumber(difference.combat)})`);
                combatElement.setAttribute('style', `color:red`);
            }
        }

        const miningElement = getProfileMiningElement();
        if (difference.mining > 0) {
            miningElement.setAttribute('data-mining-experience-ahead', `(+${formatNormalNumber(difference.mining)})`);
            miningElement.setAttribute('style', `color:lime`);
        } else {
            miningElement.setAttribute('data-mining-experience-ahead', `(${formatNormalNumber(difference.mining)})`);
            miningElement.setAttribute('style', `color:red`);
        }
    }

    function initializeProfileLoadListener() {
        let css = '[data-combat-experience-ahead]::after { content: attr(data-combat-experience-ahead); padding: 12px;}';
        css += '[data-mining-experience-ahead]::after { content: attr(data-mining-experience-ahead); padding: 12px;}';

        appendCSS(css);

        window.elethorGeneralPurposeOnLocationChange.addEventListener('EGPLocation', async function (e) {
            if (e && e.newLocation) {
                if(e.newLocation.includes('/profile/')) {
                    console.log('Profile view detected:', e);
                    const url = e.newLocation;
                    const path = url.substr(url.indexOf(profileURL));

                    // We know we have a profile lookup, and not user-data load if the length differs.
                    if (path.length > profileURL.length) {
                        const userId = Number(path.substr(path.lastIndexOf('/') + 1));

                        const difference = await getExperienceDifference(userId, currentUserData.user.id);

                        updateXPTracker(difference);
                    }
                }
            }
        });

        console.log(`[${moduleName} v${version}] Profile Load Listener initialized.`);
    }

    async function getUser(id) {
        const result = await window.axios.get(`/game/user/${id}?egpIgnoreMe=true`);
        return result.data;
    }

    window.getUser = getUser;

    async function getUserSelf() {
        const result = await window.axios.get(`/game/user`);
        return result.data;
    }

    window.getUserSelf = getUserSelf;

    async function getUserStats() {
        const result = await window.axios.get(`/game/inventory/stats`);
        return result.data;
    }

    window.getUserStats = getUserStats;

    async function getUserStatsJSON(pretty) {
        const stats = await getUserStats();

        if (pretty) {
            return JSON.stringify(stats, null, 2);
        }

        return JSON.stringify(stats);
    }

    window.getUserStatsJSON = getUserStatsJSON;

    function getUserCombatStats(user) {
        for (const skill of user.skills) {
            if (skill.name === 'combat') {
                return skill.pivot;
            }
        }
    }

    function getMiningStats(user) {
        for (const skill of user.skills) {
            if (skill.id === 1) {
                return skill.pivot;
            }
        }

        return 0;
    }

    async function getExperienceDifference(userId1, userId2) {
        const [user1, user2] = await Promise.all([
            getUser(userId1),
            getUser(userId2)
        ]);

        const combatStats1 = getUserCombatStats(user1);
        const miningStats1 = getMiningStats(user1);

        const combatStats2 = getUserCombatStats(user2);
        const miningStats2 = getMiningStats(user2);

        return {
            combat: combatStats2.experience - combatStats1.experience,
            mining: miningStats2.experience - miningStats1.experience,
        };
    }

    (async function run() {
        await waitForField(window, 'axios');
        forceLoadUser();
        initializeToastKiller();
        initializeXHRHook();
        initializeUserLoadListener();
        initializeInventoryStatsLoadListener();
        initializeLocationChangeListener();
        initializeProfileLoadListener();
        loadMarketRecyclobotVisualizer();

        console.log(`[${moduleName} v${version}] Loaded.`);
    })();

    (async function loadRerollDisableButtonModule() {
        async function waitForEcho() {
            return new Promise((resolve, reject) => {
                const interval = setInterval(() => {
                    if (window.Echo) {
                        clearInterval(interval);
                        resolve();
                    }
                }, 100);
            });
        }

        await waitForEcho();
        await waitForUser();

        elethorGeneralPurposeOnXHR.addEventListener('EGPXHR', async function (e) {
            if (e && e.xhr && e.xhr.responseURL) {
                if(e.xhr.responseURL.includes('/game/energize')) {
                    const itemID = e.xhr.responseURL.substr(e.xhr.responseURL.lastIndexOf('/')+1);
                    window.lastEnergizeID = Number(itemID);
                }
            }
        });
    })();

    (async function loadResourceNodeUpdater() {
        await waitForField(currentUserData, 'user');
        const user = await getUser(currentUserData.user.id);

        function updateExperienceRates() {
            document.querySelectorAll('#nodeContainer>div:not(:first-child)').forEach(async (node) => {
                visualizeResourceNodeExperienceRates(node, user)
            });

            function visualizeResourceNodeExperienceRates(node, user) {
                const purity = getNodePurityPercentage(node, user);
                const density = getNodeDensityPercentage(node, user);
                const experience = getNodeExperience(node, density, user);
                const ore = 16;
                const experienceRate = experience?.toFixed(2);
                const oreRate = getOreRate(density, purity, ore);

                node.children[0].children[0].setAttribute('data-after', `${experienceRate} xp/h ${oreRate} ore/h`);
            }

            function getNodePurityPercentage(node, user) {
                const column = node.children[0].children[0].children[2];
                if (!column || !column.querySelectorAll('.font-bold')[0]) return;
                const label = column.querySelectorAll('.font-bold')[0].parentElement;
                let percentage = Number(label.innerText.replace('%','').split(':')[1]);

                let miningLevel = getMiningLevel(user);
                percentage = percentage + (miningLevel * 0.1);

                return percentage;
            }

            function getNodeDensityPercentage(node, user) {
                const column = node.children[0].children[0].children[1];
                if (!column || !column.querySelectorAll('.font-bold')[0]) return;
                const label = column.querySelectorAll('.font-bold')[0].parentElement;
                let percentage = Number(label.innerText.replace('%','').split(':')[1]);

                let miningLevel = getMiningLevel(user);
                percentage = percentage + (miningLevel * 0.1);

                return percentage;
            }

            function getNodeExperience(node, density, user) {
                /** Gets the Exp: label */
                const column = node.children[0].children[0].children[3];
                if (!column || !column.querySelectorAll('.font-bold')[0]) return;
                const label = column.querySelectorAll('.font-bold')[0].parentElement;
                let value = Number(label.innerText.replace('%','').split(':')[1]);

                const skilledExtraction = getSkilledExtractionLevel(user);
                const knowledgeExtraction = getKnowledgeExtractionLevel(user) / 100;

                const ore = getActiveMining();
                const expertiseLevel = getExpertiseLevel(user);
                const expertiseGain = getExpertiseXPGainByOre(ore);
                const additionalMiningGain = expertiseLevel / 5 * expertiseGain;
                console.log('additionalMiningGain:', additionalMiningGain);

                value += additionalMiningGain;

                const actionsPerHour = getActionsPerHour(density);
                const experienceBase = value * actionsPerHour;
                const experienceSkilled = actionsPerHour * skilledExtraction;
                const experienceKnowledge = value * knowledgeExtraction;

                value = experienceBase + experienceSkilled + experienceKnowledge;

                const vip = isUserVIP(user);

                value *= vip ? 1.1 : 1;

                return value;
            }

            function getActionsPerHour(density) {
                return 3600 / (60 / (density / 100))
            }

            function getExperienceRate(density, experience) {
                return Number((3600 / (60 / (density / 100)) * experience).toFixed(2));
            }

            function getOreRate(density, purity, ore) {
                return Number((3600 / (60 / (density / 100)) * (purity / 100) * ore).toFixed(2));
            }
        }

        function isUserVIP(user) {
            return new Date(user.vip_expires) > new Date();
        }

        updateExperienceRates();
        window.elethorResourceInterval = setInterval(updateExperienceRates, 500);
        initializeResourceNodeView();

        async function initializeResourceNodeView() {
            await waitForField(document, 'head');

            let css = '[data-after]::after { content: attr(data-after); padding: 12px;}';

            appendCSS(css);
        }
    })();

    async function loadMarketRecyclobotVisualizer() {
        if (!window.egpItemPoints) {
            const companions = await getCompanions();
            const recyclobot = companions.recyclobot;
            window.egpItemPoints = recyclobot.itemPoints;

            console.log(`[${moduleName} v${version}] Companion Data initialized.`);
        }

        window.elethorGeneralPurposeOnLocationChange.addEventListener('EGPLocation', async function (e) {
            if (e && e.newLocation) {
                window.egpRecyclobotOnMarket = e.newLocation.includes('/market/');

                if (!window.egpRecyclobotOnMarket) {
                    return;
                }

                if (!window.egpItemPoints) {
                    console.warn(`[${moduleName} v${version}] Entered market page but had no recyclobot item points list.`);
                    return;
                }

                const marketObjects = getMarketListingObjects();

                for (const marketListing of marketObjects) {
                    for (const recyclobotEntry of window.egpItemPoints) {
                        if (recyclobotEntry.item_name === marketListing.name) {
                            //marketListing.element.setAttribute('data-points-recyclobot',`R: ${recyclobotEntry.quantity}:${recyclobotEntry.points}`);
                            break;
                        }
                    }
                }
            }
        });

        function getMarketListingObjects() {
            return Array.prototype.slice.call(document.querySelectorAll('.is-market-side-menu ul li')).map((e) => {return {element: e, name: e.innerText}});
        }

        async function getCompanions() {
            return (await window.axios.get('/game/companions')).data;
        }
    };

    function setPackAmount(amount) {
        window.packAmount = amount;

        if (window.packAmount < 0) {
            window.packAmount = 0;
        }
    }

    window.setPackAmount = setPackAmount;

    function formatNormalNumber(num){
        return num.toLocaleString();
    }

    function appendCSS(css) {
        let head = document.head || document.getElementsByTagName('head')[0];
        let style = document.createElement('style');

        head.appendChild(style);

        style.type = 'text/css';
        if (style.styleSheet){
            // This is required for IE8 and below.
            style.styleSheet.cssText = css;
        } else {
            style.appendChild(document.createTextNode(css));
        }
    }

    function getMiningLevel(user) {
        for (const skill of user.skills) {
            if (skill.id === 1) {
                return skill.pivot.level;
            }
        }

        return 0;
    }

    function getSkilledExtractionLevel(user) {
        for (const skill of user.skills) {
            if (skill.id === 17) {
                return skill.pivot.level;
            }
        }

        return 0;
    }

    function getKnowledgeExtractionLevel(user) {
        for (const skill of user.skills) {
            if (skill.id === 18) {
                return skill.pivot.level;
            }
        }

        return 0;
    }

    function getExpertiseLevel(user) {
        for (const skill of user.skills) {
            if (skill.id === 38) {
                return skill.pivot.level;
            }
        }

        return 0;
    }

    expertiseXPGainByOre = {
        'Orthoclase': 780,
        'Anorthite': 825,
        'Ferrisum': 600,
        'Rhenium': 465,
        'Jaspil': 480,
        'Skasix': 0,
    };

    function getExpertiseXPGainByOre(ore) {
        return expertiseXPGainByOre[ore];
    }

    function getActiveMining() {
        const activeMiningButtons = document.querySelectorAll('.buttons .button.is-success');

        if (!activeMiningButtons[0] ||
            !activeMiningButtons[0].children[0] ||
            !activeMiningButtons[0].children[0].innerText ||
            !activeMiningButtons[0].children[0].innerText.trim
        ) {
            return '';
        }

        return activeMiningButtons[0].children[0].innerText.trim();
    }

    function getPackLevel(user) {
        for (const skill of user.skills) {
            if (skill.id === 22) {
                return skill.pivot.level;
            }
        }

        return 0;
    }

    function getCarnageLevel(user) {
        for (const skill of user.skills) {
            if (skill.id === 23) {
                return skill.pivot.level;
            }
        }

        return 0;
    }

    function getAttackSpeed(speed) {
        return 50 - (50 * speed / (speed + 400));
    }

    function getHealth(fortitude) {
        return 100000 * fortitude / (fortitude + 4000);
    }

    async function waitForField(target, field) {
        return new Promise((resolve, reject) => {
            const interval = setInterval(() => {
                if (target[field] !== undefined) {
                    clearInterval(interval);
                    resolve();
                }
            }, 100);
        });
    }

    async function waitForUser() {
        return new Promise((resolve, reject) => {
            const interval = setInterval(() => {
                if (currentUserData.user && currentUserData.user.id !== undefined) {
                    clearInterval(interval);
                    resolve();
                }
            }, 100);
        });
    }

    (async function loadAlarms() {
        await waitForField(window, 'Echo');
        await waitForField(document, 'head');
        await waitForUser();


        const meta1 = document.createElement('meta');
        meta1.setAttribute('content', "media-src https://furious.no/elethor/sound/");
        meta1.setAttribute('http-equiv', 'Content-Security-Policy');
        document.getElementsByTagName('head')[0].appendChild(meta1);

        /**
         * You can add an alternative audio by setting the local storage keys:
         * localStorage.setItem('egpSoundOutOfActions', 'your-url')
         * localStorage.setItem('egpSoundQuestCompleted', 'your-url')
         * */

        // Alternative: https://furious.no/elethor/sound/elethor-actions-have-ran-out.mp3
        window.egpSoundOutOfActions =
            localStorage.getItem('egpSoundOutOfActions') || 'https://furious.no/elethor/sound/out-of-actions.wav';

        // Alternative: https://furious.no/elethor/sound/elethor-quest-completed.mp3
        window.egpSoundQuestCompleted =
            localStorage.getItem('egpSoundQuestCompleted') || 'https://furious.no/elethor/sound/quest-complete.wav';

        const masterAudioLevel = 0.3;

        window.Echo.private(`App.User.${currentUserData.user.id}`).listen(".App\\Domain\\Monster\\Events\\FightingAgain", handleActionData);
        window.Echo.private(`App.User.${currentUserData.user.id}`).listen(".App\\Domain\\ResourceNode\\Events\\GatheringAgain", handleActionData);
        window.Echo.private(`App.User.${currentUserData.user.id}`).listen(".App\\Domain\\Quest\\Events\\UpdateQuestProgress", handleQuestData);

        let hasWarnedAboutActions = false;
        let hasWarnedAboutQuest = false;

        /**
         * Warns once when action runs out.
         * */
        function handleActionData(data) {
            if (data && data.action) {
                if (data.action.remaining <= 0 && !hasWarnedAboutActions) {
                    playOutOfActions();
                    hasWarnedAboutActions = true;
                } else if (data.action.remaining > 0 && hasWarnedAboutActions) {
                    hasWarnedAboutActions = false;
                }
            }
        }
        window.handleActionData = handleActionData;

        /**
         * Warns once when quest completes.
         * */
        function handleQuestData(data) {
            if (data
                && data.step
                && data.step.tasks) {
                if (data.step.progress >= data.step.tasks[0].quantity && !hasWarnedAboutQuest) {
                    playQuestCompleted();
                    hasWarnedAboutQuest = true;
                } else if (data.step.progress < data.step.tasks[0].quantity && hasWarnedAboutQuest) {
                    hasWarnedAboutQuest = false;
                }
            }
        }
        window.handleQuestData = handleQuestData;

        function playOutOfActions() {
            playSound(window.egpSoundOutOfActions);
        }
        window.playOutOfActions = playOutOfActions;

        function playQuestCompleted() {
            playSound(window.egpSoundQuestCompleted);
        }
        window.playQuestCompleted = playQuestCompleted;

        function playSound(sound, volume = masterAudioLevel){
            const audio = new Audio(sound);

            audio.volume = volume;
            audio.play();
        }
    })();
})();