Melvor Idle - AutoMastery

Automatically spends mastery when a pool is about to fill up

// ==UserScript==
// @name        Melvor Idle - AutoMastery
// @description Automatically spends mastery when a pool is about to fill up
// @version     3.1
// @namespace   Visua
// @match       https://melvoridle.com/*
// @match       https://www.melvoridle.com/*
// @grant       none
// ==/UserScript==
/* jshint esversion: 6 */

((main) => {
    var script = document.createElement('script');
    script.textContent = `try { (${main})(); } catch (e) { console.log(e); }`;
    document.body.appendChild(script).parentNode.removeChild(script);
})(() => {
    'use strict';

    /**
     *
     * @param {number} skill
     * @param {number[]} masteries
     * @returns {{ id: number, xp: number, toNext: number }[]}
     */
    function getNonMaxedMasteries(skill, masteries) {
        return masteries.map(id => ({ id, xp: MASTERY[skill].xp[id], toNext: getMasteryXpForNextLevel(skill, id) })).filter(m => m.toNext > 0);
    }

    /**
     *
     * @param {{ id: number, xp: number, toNext: number }[]} masteries
     * @param {number} xpOverCheckpoint
     * @param {boolean} selectLowest
     * @returns
     */
    function getAffordableMastery(masteries, xpOverCheckpoint, selectLowest) {
        return masteries
            .reduce(
                (best, m) => {
                    if (m.toNext <= xpOverCheckpoint && (best.id === -1 || (selectLowest ? m.xp <= best.xp : m.xp >= best.xp))) {
                        return m;
                    } else {
                        return best;
                    }
                },
                { id: -1, xp: 0, toNext: 0 }
            ).id;
    }

    function autoSpendMasteryPool(skill, xpToBeAdded) {
        const poolXp = MASTERY[skill].pool;
        const poolMax = getMasteryPoolTotalXP(skill);
        if (poolXp + xpToBeAdded >= poolMax * AUTOMASTERY.settings[skill].spendWhenPoolReaches / 100) {
            const xpOverCheckpoint = (poolXp + xpToBeAdded) - (poolMax * AUTOMASTERY.settings[skill].threshold / 100);

            let masteryToLevel = -1;
            let reason = '';

            // Only look at selected non-maxed masteries
            let masteries = getNonMaxedMasteries(skill, AUTOMASTERY.settings[skill].selectedMasteries);
            if (!masteries.length) {
                // If no (non-maxed) masteries selected look at all masteries
                masteries = getNonMaxedMasteries(skill, MASTERY[skill].xp.map((_, id) => id));
            }

            if (!masteries.length) {
                return;
            }

            if (masteryToLevel === -1) {
                // Find the lowest or highest (depending on setting) mastery that can be afforded
                masteryToLevel = getAffordableMastery(masteries, xpOverCheckpoint, AUTOMASTERY.settings[skill].selectLowest);
                reason = `was the ${AUTOMASTERY.settings[skill].selectLowest ? 'lowest' : 'highest'} that could be leveled without dropping below ${AUTOMASTERY.settings[skill].threshold}%`;
            }

            if (masteryToLevel === -1) {
                // Find the cheapest mastery since we can't afford any
                const cheapest = masteries.reduce((cheapest, m) => m.toNext <= cheapest.toNext ? m : cheapest);
                if (cheapest.toNext < poolXp) {
                    masteryToLevel = cheapest.id;
                }
                reason = `was the cheapest to level and we are forced to drop below ${AUTOMASTERY.settings[skill].threshold}%`;
            }

            if (masteryToLevel !== -1) {
                const message = `AutoMastery: Leveled up ${getMasteryName(skill, masteryToLevel)} to ${getMasteryLevel(skill, masteryToLevel) + 1}`;
                const cost = getMasteryXpForNextLevel(skill, masteryToLevel);
                const details = `Earned ${numberWithCommas(xpToBeAdded.toFixed(3))} XP. `
                    + `Pool before: ${((poolXp / poolMax) * 100).toFixed(3)}%. `
                    + `Pool after: ${(((poolXp + xpToBeAdded - cost) / poolMax) * 100).toFixed(3)}%`;
                console.log(`${message} for ${numberWithCommas(Math.round(cost))} XP because it ${reason} (${details})`);
                autoMasteryNotify(message);
                const _showSpendMasteryXP = showSpendMasteryXP;
                showSpendMasteryXP = () => {};
                try {
                    levelUpMasteryWithPool(skill, masteryToLevel);
                } catch (e) {
                    console.error(e);
                } finally {
                    showSpendMasteryXP = _showSpendMasteryXP;
                }
                autoSpendMasteryPool(skill, xpToBeAdded);
            }
        }
    }

    function autoMasteryNotify(message) {
        Toastify({
            text: `<div class="text-center"><img class="notification-img" src="assets/media/main/mastery_pool.svg"><span class="badge badge-success">${message}</span></div>`,
            duration: 5000,
            gravity: 'bottom',
            position: 'center',
            backgroundColor: 'transparent',
            stopOnFocus: false,
        }).showToast();
    }

    function autoMastery() {
        // Load settings
        const settings = Object.keys(SKILLS).map(s => ({ threshold: 95, spendWhenPoolReaches: 100, selectLowest: true, selectedMasteries: [] }));
        const savedSettings = JSON.parse(localStorage.getItem(`AutoMastery-${currentCharacter}`));
        if (savedSettings) {
            settings.splice(0, savedSettings.length, ...savedSettings);
        }

        // Validate and save settings on change
        const settingsHandler = {
            set: function (obj, prop, value) {
                if (prop === 'threshold') {
                    if (!Number.isInteger(value)) {
                        throw new TypeError('threshold should be an integer');
                    }
                    if (value < 0 || value > 95) {
                        throw new RangeError('threshold should be a number from 0 to 95');
                    }
                } else if (prop === 'spendWhenPoolReaches') {
                    if (!Number.isInteger(value)) {
                        throw new TypeError('spendWhenPoolReaches should be an integer');
                    }
                    if (value < 0 || value > 100) {
                        throw new RangeError('spendWhenPoolReaches should be a number from 0 to 100');
                    }
                } else if (prop === 'selectLowest') {
                    if (typeof value !== 'boolean') {
                        throw new TypeError('selectLowest should be a boolean');
                    }
                } else if (prop === 'selectedMasteries') {
                    if (!Array.isArray(value) || value.some(e => !Number.isInteger(e))) {
                        throw new TypeError('selectedMasteries should be an array of integers');
                    }
                }

                obj[prop] = value;
                localStorage.setItem(`AutoMastery-${currentCharacter}`, JSON.stringify(AUTOMASTERY.settings));
                console.log('Settings saved');
                return true;
            },
        };

        window.AUTOMASTERY = {
            settings: settings.map(skillSettings => new Proxy(skillSettings, settingsHandler)),
        };

        // Inject
        const _addMasteryXPToPool = addMasteryXPToPool;
        addMasteryXPToPool = (...args) => {
            const _masteryPoolLevelUp = masteryPoolLevelUp;
            masteryPoolLevelUp = 1;
            try {
                const skill = args[0];
                let xpToBeAdded = args[1];
                const token = args[3];
                if (xpToBeAdded > 0) {
                    if (skillLevel[skill] >= 99 && !token) {
                        xpToBeAdded /= 2;
                    } else if (!token) {
                        xpToBeAdded /= 4;
                    }
                    autoSpendMasteryPool(skill, xpToBeAdded);
                }
            } catch (e) {
                console.error(e);
            } finally {
                masteryPoolLevelUp = _masteryPoolLevelUp;
                _addMasteryXPToPool(...args);
            }
        };
    }

    function loadScript() {
        if (typeof confirmedLoaded !== 'undefined' && confirmedLoaded) {
            clearInterval(interval);
            console.log('Loading AutoMastery');
            autoMastery();
        }
    }

    const interval = setInterval(loadScript, 500);
});