Melvor ETA

Shows xp/h and mastery xp/h, and the time remaining until certain targets are reached. Takes into account Mastery Levels and other bonuses.

当前为 2021-06-11 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name		Melvor ETA
// @namespace	http://tampermonkey.net/
// @version		0.5.5-0.20
// @description	Shows xp/h and mastery xp/h, and the time remaining until certain targets are reached. Takes into account Mastery Levels and other bonuses.
// @description	Please report issues on https://github.com/gmiclotte/melvor-scripts/issues or message TinyCoyote#1769 on Discord
// @description	The last part of the version number is the most recent version of Melvor that was tested with this script. More recent versions might break the script.
// @description	Forked from Breindahl#2660's Melvor TimeRemaining script v0.6.2.2., originally developed by Breindahl#2660, Xhaf#6478 and Visua#9999
// @author		GMiclotte
// @match		https://*.melvoridle.com/*
// @exclude		https://wiki.melvoridle.com*
// @noframes
// @grant		none
// ==/UserScript==

function script() {
    if (window.ETA !== undefined) {
        console.error('ETA is already loaded!');
    } else {
        createETASettings();
        createETA();
        loadETA();
    }

    function createETASettings() {
        // settings can be changed from the console, the default values here will be overwritten by the values in localStorage['ETASettings']
        window.ETASettings = {
            /*
                toggles
             */
            // true for 12h clock (AM/PM), false for 24h clock
            IS_12H_CLOCK: false,
            // true for short clock `xxhxxmxxs`, false for long clock `xx hours, xx minutes and xx seconds`
            IS_SHORT_CLOCK: true,
            // true for alternative main display with xp/h, mastery xp/h and action count
            SHOW_XP_RATE: true,
            // true to show action times
            SHOW_ACTION_TIME: false,
            // true to allow final pool percentage > 100%
            UNCAP_POOL: true,
            // true will show the current xp/h and mastery xp/h; false shows average if using all resources
            // does not affect anything if SHOW_XP_RATE is false
            CURRENT_RATES: true,
            // set to true to include mastery tokens in time until 100% pool
            USE_TOKENS: false,
            // set to true to show partial level progress in the ETA tooltips
            SHOW_PARTIAL_LEVELS: false,
            // set to true to hide the required resources in the ETA tooltips
            HIDE_REQUIRED: false,
            // set to true to include "potential" Summoning exp from created tablets
            USE_TABLETS: false,
            // set to true to play a sound when we run out of resources or reach a target
            DING_RESOURCES: true,
            DING_LEVEL: true,
            DING_MASTERY: true,
            DING_POOL: true,
            // change the ding sound level
            DING_VOLUME: 0.1,
            /*
                targets
             */
            // Default global target level / mastery / pool% is 99 / 99 / 100
            GLOBAL_TARGET_LEVEL: 99,
            GLOBAL_TARGET_MASTERY: 99,
            GLOBAL_TARGET_POOL: 100,
            // skill specific targets can be defined here, these override the global targets
            TARGET_LEVEL: {
                // [CONSTANTS.skill.Firemaking]: 120,
            },
            TARGET_MASTERY: {
                // [CONSTANTS.skill.Herblore]: 90,
            },
            TARGET_POOL: {
                // [CONSTANTS.skill.Crafting]: 25,
            },
            // returns the appropriate target
            getNext: (current, list) => {
                if (list === undefined) {
                    return list
                }
                if (list.length !== undefined) {
                    for (let i = 0; i < list.length; i++) {
                        if (list[i] > current) {
                            return list[i];
                        }
                    }
                    return Math.max(list);
                }
                return list;
            },
            getTarget: (current, global, specific, defaultTarget) => {
                if (current !== null) {
                    global = ETASettings.getNext(current, global);
                    specific = ETASettings.getNext(current, specific);
                }
                let target = defaultTarget;
                if (Number.isInteger(global)) {
                    target = global;
                }
                if (Number.isInteger(specific)) {
                    target = specific;
                }
                if (target <= 0) {
                    target = defaultTarget;
                }
                return Math.ceil(target);
            },
            getTargetLevel: (skillID, currentLevel) => {
                return ETASettings.getTarget(currentLevel, ETASettings.GLOBAL_TARGET_LEVEL, ETASettings.TARGET_LEVEL[skillID], 99);
            },
            getTargetMastery: (skillID, currentMastery) => {
                return ETASettings.getTarget(currentMastery, ETASettings.GLOBAL_TARGET_MASTERY, ETASettings.TARGET_MASTERY[skillID], 99);
            },
            getTargetPool: (skillID, currentPool) => {
                return ETASettings.getTarget(currentPool, ETASettings.GLOBAL_TARGET_POOL, ETASettings.TARGET_POOL[skillID], 100);
            },

            /*
                methods
             */
            // save settings to local storage
            save: () => {
                window.localStorage['ETASettings'] = window.JSON.stringify(window.ETASettings);
            }
        };
    }

    function createETA() {
        // global object
        window.ETA = {};

        ETA.log = function (...args) {
            console.log("Melvor ETA:", ...args)
        }

        ETA.createSettingsMenu = () => {
            // check if combat sim methods are available
            if (window.MICSR === undefined || MICSR.TabCard === undefined) {
                ETA.menuCreationAttempts = (ETA.menuCreationAttempts || 0) + 1;
                if (ETA.menuCreationAttempts > 10) {
                    ETA.log('Failed to add settings menu!')
                } else {
                    // try again in 50 ms
                    setTimeout(ETA.createSettingsMenu, 50);
                }
                return;
            }

            // set names
            ETA.menuItemID = 'etaButton';
            ETA.modalID = 'etaModal';

            // clean up in case elements already exist
            MICSR.destroyMenu(ETA.menuItemID, ETA.modalID);

            // create wrappers and access point
            ETA.content = document.createElement('div');
            ETA.content.className = 'mcsTabContent';
            MICSR.addMenuItem('ETA Settings', 'assets/media/main/settings_header.svg', ETA.menuItemID, 'etaModal')
            MICSR.addModal('ETA Settings', ETA.modalID, [ETA.content])

            // add toggles card
            ETA.addToggles();

            // add global target card
            ETA.addGlobalTargetInputs();

            // add target card
            ETA.addTargetInputs();

            // log
            ETA.log('added settings menu!')
        }

        ETA.addToggles = () => {
            ETA.togglesCard = new MICSR.Card(ETA.content, '', '150px', true);
            const titles = {
                IS_12H_CLOCK: 'Use 12h clock',
                IS_SHORT_CLOCK: 'Use short time format',
                SHOW_XP_RATE: 'Show XP rates',
                SHOW_ACTION_TIME: 'Show action times',
                UNCAP_POOL: 'Show pool past 100%',
                CURRENT_RATES: 'Show current rates',
                USE_TOKENS: '"Use" Mastery tokens for final Pool %',
                SHOW_PARTIAL_LEVELS: 'Show partial levels',
                HIDE_REQUIRED: 'Hide required resources',
                DING_RESOURCES: 'Ding when out of resources',
                DING_LEVEL: 'Ding on level target',
                DING_MASTERY: 'Ding on mastery target',
                DING_POOL: 'Ding on pool target',
                USE_TABLETS: '"Use" all created Summoning Tablets',
            };
            Object.getOwnPropertyNames(titles).forEach(property => {
                const title = titles[property];
                ETA.togglesCard.addToggleRadio(
                    title,
                    property,
                    ETASettings,
                    property,
                    ETASettings[property],
                );
            });
        }

        ETA.addGlobalTargetInputs = () => {
            ETA.globalTargetsCard = new MICSR.Card(ETA.content, '', '150px', true);
            [
                {id: 'LEVEL', label: 'Global level targets', defaultValue: [99]},
                {id: 'MASTERY', label: 'Global mastery targets', defaultValue: [99]},
                {id: 'POOL', label: 'Global pool targets (%)', defaultValue: [100]},
            ].forEach(target => {
                const globalKey = 'GLOBAL_TARGET_' + target.id;
                ETA.globalTargetsCard.addNumberArrayInput(
                    target.label,
                    ETASettings,
                    globalKey,
                    target.defaultValue
                );
            });

        }

        ETA.addTargetInputs = () => {
            ETA.skillTargetCard = new MICSR.TabCard('ETA-target', true, ETA.content, '', '150px', true);
            [
                CONSTANTS.skill.Woodcutting,
                CONSTANTS.skill.Fishing,
                CONSTANTS.skill.Firemaking,
                CONSTANTS.skill.Cooking,
                CONSTANTS.skill.Mining,
                CONSTANTS.skill.Smithing,
                CONSTANTS.skill.Thieving,
                CONSTANTS.skill.Fletching,
                CONSTANTS.skill.Crafting,
                CONSTANTS.skill.Runecrafting,
                CONSTANTS.skill.Herblore,
                CONSTANTS.skill.Agility,
                CONSTANTS.skill.Magic,
            ].forEach(i => {
                const card = ETA.skillTargetCard.addTab(SKILLS[i].name, SKILLS[i].media, '', '150px');
                card.addSectionTitle(SKILLS[i].name + ' Targets');
                [
                    {id: 'LEVEL', label: 'Level targets'},
                    {id: 'MASTERY', label: 'Mastery targets'},
                    {id: 'POOL', label: 'Pool targets (%)'},
                ].forEach(target => {
                    const key = 'TARGET_' + target.id;
                    card.addNumberArrayInput(
                        target.label,
                        ETASettings[key],
                        i,
                    );
                });
            });
        }

        ////////
        //ding//
        ////////
        // Function to check if task is complete
        ETA.taskComplete = function () {
            const last = ETA.timeLeftLast;
            const current = ETA.timeLeftCurrent;
            if (last === undefined) {
                return;
            }
            if (last.skillID !== current.skillID) {
                // started a different skill, don't ding
                return;
            }
            if (last.action !== current.action) {
                // started a different action, don't ding
                return;
            }
            if (last.times.length !== current.times.length) {
                // ding settings were changed, don't ding
                return;
            }
            // ding if any targets were reached
            for (let i = 0; i < last.times.length; i++) {
                const lastTime = last.times[i];
                const currentTime = current.times[i];
                if (lastTime.current >= lastTime.target) {
                    // target already reached
                    continue;
                }
                if (currentTime.current >= lastTime.target) { // current level is higher than previous target
                    notifyPlayer(last.skillID, currentTime.msg, "danger");
                    ETA.log(currentTime.msg);
                    let ding = new Audio("https://www.myinstants.com/media/sounds/ding-sound-effect.mp3");
                    ding.volume = ETASettings.DING_VOLUME;
                    ding.play();
                    return;
                }
            }
        }

        ETA.time = (ding, target, current, msg) => {
            return {ding: ding, target: target, current: current, msg: msg};
        };

        ETA.setTimeLeft = function (initial, times) {
            // save previous
            ETA.timeLeftLast = ETA.timeLeftCurrent;
            // set current
            ETA.timeLeftCurrent = {
                skillID: initial.skillID,
                action: initial.currentAction.toString(),
                times: times.filter(x => x.ding),
            }
        }


        //////////////
        //containers//
        //////////////

        const html2Node = (html) => {
            html = html.trim(); // Never return a text node of whitespace as the result
            return $(html)[0];
            // var template = document.createElement('template');
            // template.innerHTML = html;
            // return template.content.firstChild;
        }

        const tempContainer = (id) => {
            return html2Node(''
                + '<div class="font-size-base font-w600 text-center text-muted">'
                + `	<small id ="${id}" class="mb-2" style="display:block;clear:both;white-space:pre-line" data-toggle="tooltip" data-placement="top" data-html="true" title="" data-original-title="">`
                + '	</small>'
                + `	<small id ="${id}" class="mb-2" style="display:block;clear:both;white-space:pre-line">`
                + `<div id="${id + '-YouHave'}"/>`
                + '	</small>'
                + '</div>');
        }

        ETA.makeProcessingDisplays = function () {
            // smithing
            let node = document.getElementById('smith-item-have');
            node.parentNode.insertBefore(tempContainer('timeLeftSmithing'), node.nextSibling);
            // fletching
            node = document.getElementById('fletch-item-have');
            node.parentNode.insertBefore(tempContainer('timeLeftFletching'), node.nextSibling);
            // Runecrafting
            node = document.getElementById('runecraft-item-have');
            node.parentNode.insertBefore(tempContainer('timeLeftRunecrafting'), node.nextSibling);
            // Crafting
            node = document.getElementById('craft-item-have');
            node.parentNode.insertBefore(tempContainer('timeLeftCrafting'), node.nextSibling);
            // Herblore
            node = document.getElementById('herblore-item-have');
            node.parentNode.insertBefore(tempContainer('timeLeftHerblore'), node.nextSibling);
            // Cooking
            node = document.getElementById('skill-cooking-food-selected-qty');
            node = node.parentNode.parentNode.parentNode;
            node.parentNode.insertBefore(tempContainer('timeLeftCooking'), node.nextSibling);
            // Firemaking
            node = document.getElementById('skill-fm-logs-selected-qty');
            node = node.parentNode.parentNode.parentNode;
            node.parentNode.insertBefore(tempContainer('timeLeftFiremaking'), node.nextSibling);
            // Alt. Magic
            node = document.getElementById('magic-item-have-and-div');
            node.parentNode.insertBefore(tempContainer('timeLeftMagic'), node.nextSibling);
            // Summoning
            node = document.getElementById('summoning-item-have');
            if (node) {
                node.parentNode.insertBefore(tempContainer('timeLeftSummoning'), node.nextSibling);
            }
        }

        ETA.makeMiningDisplay = function () {
            miningData.forEach((_, i) => {
                const node = document.getElementById(`mining-ore-img-${i}`);
                node.parentNode.insertBefore(tempContainer(`timeLeftMining-${i}`), node);
            });
        }

        ETA.makeThievingDisplay = function () {
            thievingNPC.forEach((_, i) => {
                const node = document.getElementById(`success-rate-${i}`).parentNode;
                node.parentNode.insertBefore(tempContainer(`timeLeftThieving-${i}`), node.nextSibling);
            });
        }

        ETA.makeWoodcuttingDisplay = function () {
            trees.forEach((_, i) => {
                const node = document.getElementById(`tree-rates-${i}`);
                node.parentNode.insertBefore(tempContainer(`timeLeftWoodcutting-${i}`), node.nextSibling);
            });
            const node = document.getElementById('skill-woodcutting-multitree').parentNode;
            node.parentNode.insertBefore(tempContainer('timeLeftWoodcutting-Secondary'), node.nextSibling);
        }

        ETA.makeFishingDisplay = function () {
            fishingAreas.forEach((_, i) => {
                const node = document.getElementById(`fishing-area-${i}-selected-fish-xp`);
                node.parentNode.insertBefore(tempContainer(`timeLeftFishing-${i}`), node.nextSibling);
            });
        }

        ETA.makeAgilityDisplay = function () {
            chosenAgilityObstacles.forEach(i => {
                if (i === -1) {
                    return;
                }
                if (document.getElementById(`timeLeftAgility-${i}`)) {
                    // element already exists
                    return;
                }
                let node = document.getElementById(`agility-obstacle-${i}`);
                node = node.children[0].children[0].children[0];
                node.insertBefore(tempContainer(`timeLeftAgility-${i}`), node.children[3]);
            });
            if (document.getElementById('timeLeftAgility-Secondary')) {
                // element already exists
                return;
            }
            document.getElementById('agility-breakdown-items').appendChild(tempContainer('timeLeftAgility-Secondary'));
        }

        ////////////////
        //main wrapper//
        ////////////////

        ETA.timeRemainingWrapper = function (skillID, checkTaskComplete) {
            // populate the main `time remaining` variables
            if (isGathering(skillID)) {
                gatheringWrapper(skillID, checkTaskComplete);
            } else {
                productionWrapper(skillID, checkTaskComplete);
            }
        }

        function gatheringWrapper(skillID, checkTaskComplete) {
            let data = [];
            let current;
            // gathering skills
            switch (skillID) {
                case CONSTANTS.skill.Mining:
                    data = miningData;
                    current = currentRock;
                    break;

                case CONSTANTS.skill.Thieving:
                    data = thievingNPC;
                    current = npcID;
                    break;

                case CONSTANTS.skill.Woodcutting:
                    data = trees;
                    current = -1; // never progress bar or ding for single tree
                    break;

                case CONSTANTS.skill.Fishing:
                    data = fishingAreas;
                    current = currentFishingArea;
                    break;

                case CONSTANTS.skill.Agility:
                    data = [];
                    // only keep active chosen obstacles
                    for (const x of chosenAgilityObstacles) {
                        if (x >= 0) {
                            data.push(x);
                        } else {
                            break;
                        }
                    }
                    current = -1; // never progress bar or ding for single obstacle
                    break
            }
            if (data.length > 0) {
                if (skillID !== CONSTANTS.skill.Agility) {
                    data.forEach((x, i) => {
                        if (skillID === CONSTANTS.skill.Woodcutting && currentlyCutting === 2 && currentTrees.includes(i)) {
                            return;
                        }
                        let initial = initialVariables(skillID, checkTaskComplete);
                        if (initial.skillID === CONSTANTS.skill.Fishing) {
                            initial.fishID = selectedFish[i];
                            if (initial.fishID === null) {
                                return;
                            }
                        }
                        initial.currentAction = i;
                        if (initial.skillID === CONSTANTS.skill.Agility) {
                            initial.currentAction = x;
                            initial.agilityObstacles = data;
                        }
                        asyncTimeRemaining(initial);
                    });
                }
                if (skillID === CONSTANTS.skill.Woodcutting) {
                    if (currentlyCutting === 2) {
                        // init first tree
                        let initial = initialVariables(skillID, checkTaskComplete);
                        initial.currentAction = currentTrees;
                        initial.multiple = ETA.PARALLEL;
                        // run time remaining
                        asyncTimeRemaining(initial);
                    } else {
                        // wipe the display, there's no way of knowing which tree is being cut
                        document.getElementById(`timeLeft${skillName[skillID]}-Secondary`).textContent = '';
                    }
                }
                if (skillID === CONSTANTS.skill.Agility) {
                    // init first tree
                    let initial = initialVariables(skillID, checkTaskComplete);
                    initial.currentAction = data;
                    initial.agilityObstacles = data;
                    initial.multiple = ETA.SEQUENTIAL;
                    // run time remaining
                    asyncTimeRemaining(initial);
                }
            }
        }

        function productionWrapper(skillID, checkTaskComplete) {
            // production skills
            let initial = initialVariables(skillID, checkTaskComplete);
            switch (initial.skillID) {
                case CONSTANTS.skill.Smithing:
                    initial.currentAction = selectedSmith;
                    break;
                case CONSTANTS.skill.Fletching:
                    initial.currentAction = selectedFletch;
                    break;
                case CONSTANTS.skill.Runecrafting:
                    initial.currentAction = selectedRunecraft;
                    break;
                case CONSTANTS.skill.Crafting:
                    initial.currentAction = selectedCraft;
                    break;
                case CONSTANTS.skill.Herblore:
                    initial.currentAction = selectedHerblore;
                    break;
                case CONSTANTS.skill.Cooking:
                    initial.currentAction = selectedFood;
                    break;
                case CONSTANTS.skill.Firemaking:
                    initial.currentAction = selectedLog;
                    break;
                case CONSTANTS.skill.Magic:
                    initial.currentAction = selectedAltMagic;
                    break;
                case CONSTANTS.skill.Summoning:
                    initial.currentAction = selectedSummon;
            }
            if (initial.currentAction === undefined || initial.currentAction === null) {
                return;
            }
            asyncTimeRemaining(initial);
        }

        function asyncTimeRemaining(initial) {
            setTimeout(
                function () {
                    timeRemaining(initial);
                },
                0,
            );
        }

        ////////////////////
        //internal methods//
        ////////////////////
        // Function to get unformatted number for Qty
        function getQtyOfItem(itemID) {
            if (itemID === -4) {
                return gp;
            }
            if (itemID === -5) {
                return slayerCoins;
            }
            const bankID = getBankId(itemID);
            if (bankID === -1) {
                return 0;
            }
            return bank[bankID].qty;
        }

        // help function for time display
        function appendName(t, name, isShortClock) {
            if (t === 0) {
                return "";
            }
            if (isShortClock) {
                return t + name[0];
            }
            let result = t + " " + name;
            if (t === 1) {
                return result;
            }
            return result + "s";
        }

        // Convert milliseconds to hours/minutes/seconds and format them
        function msToHms(ms, isShortClock = ETASettings.IS_SHORT_CLOCK) {
            let seconds = Number(ms / 1000);
            // split seconds in days, hours, minutes and seconds
            let d = Math.floor(seconds / 86400)
            let h = Math.floor(seconds % 86400 / 3600);
            let m = Math.floor(seconds % 3600 / 60);
            let s = Math.floor(seconds % 60);
            // no comma in short form
            // ` and ` if hours and minutes or hours and seconds
            // `, ` if hours and minutes and seconds
            let dDisplayComma = " ";
            if (!isShortClock && d > 0) {
                let count = (h > 0) + (m > 0) + (s > 0);
                if (count === 1) {
                    dDisplayComma = " and ";
                } else if (count > 1) {
                    dDisplayComma = ", ";
                }
            }
            let hDisplayComma = " ";
            if (!isShortClock && h > 0) {
                let count = (m > 0) + (s > 0);
                if (count === 1) {
                    hDisplayComma = " and ";
                } else if (count > 1) {
                    hDisplayComma = ", ";
                }
            }
            // no comma in short form
            // ` and ` if minutes and seconds
            let mDisplayComma = " ";
            if (!isShortClock && m > 0) {
                if (s > 0) {
                    mDisplayComma = " and ";
                }
            }
            // append h/hour/hours etc depending on isShortClock, then concat and return
            return appendName(d, "day", isShortClock) + dDisplayComma
                + appendName(h, "hour", isShortClock) + hDisplayComma
                + appendName(m, "minute", isShortClock) + mDisplayComma
                + appendName(s, "second", isShortClock);
        }

        // Add seconds to date
        function addMSToDate(date, ms) {
            return new Date(date.getTime() + ms);
        }

        // Format date 24 hour clock
        function dateFormat(now, then, is12h = ETASettings.IS_12H_CLOCK) {
            let format = {weekday: "short", month: "short", day: "numeric"};
            let date = then.toLocaleString(undefined, format);
            if (date === now.toLocaleString(undefined, format)) {
                date = "";
            } else {
                date += " at ";
            }
            let hours = then.getHours();
            let minutes = then.getMinutes();
            // convert to 12h clock if required
            let amOrPm = '';
            if (is12h) {
                amOrPm = hours >= 12 ? 'pm' : 'am';
                hours = (hours % 12) || 12;
            } else {
                // only pad 24h clock hours
                hours = hours < 10 ? '0' + hours : hours;
            }
            // pad minutes
            minutes = minutes < 10 ? '0' + minutes : minutes;
            // concat and return remaining time
            return date + hours + ':' + minutes + amOrPm;
        }

        // Convert level to Xp needed to reach that level
        function convertLvlToXp(level) {
            if (level === Infinity) {
                return Infinity;
            }
            let xp = 0;
            if (level === 1) {
                return xp;
            }
            xp = ETA.lvlToXp[level] + 1;
            return xp;
        }

        // binary search for optimization
        function binarySearch(array, pred) {
            let lo = -1, hi = array.length;
            while (1 + lo < hi) {
                const mi = lo + ((hi - lo) >> 1);
                if (pred(array[mi])) {
                    hi = mi;
                } else {
                    lo = mi;
                }
            }
            return hi;
        }

        // Convert Xp value to level
        function convertXpToLvl(xp, noCap = false) {
            let level = binarySearch(ETA.lvlToXp, (t) => (xp <= t)) - 1;
            if (level < 1) {
                level = 1;
            } else if (!noCap && level > 99) {
                level = 99;
            }
            return level;
        }

        // Get Mastery Level of given Skill and Mastery ID
        function getMasteryLevel(skill, masteryID) {
            return convertXpToLvl(MASTERY[skill].xp[masteryID]);
        }

        // Progress in current level
        function getPercentageInLevel(currentXp, finalXp, type, bar = false) {
            let currentLevel = convertXpToLvl(currentXp, true);
            if (currentLevel >= 99 && (type === "mastery" || bar === true)) return 0;
            let currentLevelXp = convertLvlToXp(currentLevel);
            let nextLevelXp = convertLvlToXp(currentLevel + 1);
            let diffLevelXp = nextLevelXp - currentLevelXp;
            let currentLevelPercentage = (currentXp - currentLevelXp) / diffLevelXp * 100;
            if (bar === true) {
                let finalLevelPercentage = ((finalXp - currentXp) > (nextLevelXp - currentXp)) ? 100 - currentLevelPercentage : ((finalXp - currentXp) / diffLevelXp * 100).toFixed(4);
                return finalLevelPercentage;
            } else {
                return currentLevelPercentage;
            }
        }

        //Return the preservation for any mastery and pool
        masteryPreservation = (initial, masteryXp, poolXp) => {
            if (!initial.hasMastery) {
                return 0;
            }
            const masteryLevel = convertXpToLvl(masteryXp);

            // modifiers and base rhaelyx
            let preservationChance = initial.staticPreservation;
            // skill specific bonuses
            switch (initial.skillID) {
                case CONSTANTS.skill.Cooking:
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 10;
                    }
                    break;
                case CONSTANTS.skill.Smithing:
                    if (masteryLevel >= 99) {
                        preservationChance += 30;
                    } else if (masteryLevel >= 80) {
                        preservationChance += 20;
                    } else if (masteryLevel >= 60) {
                        preservationChance += 15;
                    } else if (masteryLevel >= 40) {
                        preservationChance += 10;
                    } else if (masteryLevel >= 20) {
                        preservationChance += 5;
                    }
                    if (poolReached(initial, poolXp, 1)) {
                        preservationChance += 5;
                    }
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 5;
                    }
                    break;
                case CONSTANTS.skill.Fletching:
                    preservationChance += 0.2 * masteryLevel - 0.2;
                    if (masteryLevel >= 99) {
                        preservationChance += 5;
                    }
                    break;
                case CONSTANTS.skill.Crafting:
                    preservationChance += 0.2 * masteryLevel - 0.2;
                    if (masteryLevel >= 99) {
                        preservationChance += 5;
                    }
                    if (poolReached(initial, poolXp, 1)) {
                        preservationChance += 5;
                    }
                    break;
                case CONSTANTS.skill.Runecrafting:
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 10;
                    }
                    break;
                case CONSTANTS.skill.Herblore:
                    preservationChance += 0.2 * masteryLevel - 0.2;
                    if (masteryLevel >= 99) preservationChance += 5;
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 5;
                    }
                    break;
            }
            // rhaelyx is handled outside of this function

            // cap preservation to 80%
            if (preservationChance > 80) {
                preservationChance = 80;
            }
            return preservationChance;
        }

        function poolReached(initial, poolXp, idx) {
            if (initial.completionCape) {
                return true;
            }
            return poolXp >= initial.poolLim[idx];
        }

        // Adjust interval based on unlocked bonuses
        function intervalAdjustment(initial, poolXp, masteryXp, skillInterval) {
            let flatReduction = initial.flatIntervalReduction;
            let percentReduction = initial.percentIntervalReduction;
            let adjustedInterval = skillInterval;
            // compute mastery or pool dependent modifiers
            switch (initial.skillID) {
                case CONSTANTS.skill.Woodcutting:
                    if (convertXpToLvl(masteryXp) >= 99) {
                        flatReduction += 200;
                    }
                    break;
                case CONSTANTS.skill.Firemaking:
                    if (poolReached(initial, poolXp, 1)) {
                        percentReduction += 10;
                    }
                    percentReduction += convertXpToLvl(masteryXp) * 0.1;
                    break;
                case CONSTANTS.skill.Mining:
                    if (poolReached(initial, poolXp, 2)) {
                        flatReduction += 200;
                    }
                    break;
                case CONSTANTS.skill.Crafting:
                    if (poolReached(initial, poolXp, 2)) {
                        flatReduction += 200;
                    }
                    break;
                case CONSTANTS.skill.Fletching:
                    if (poolReached(initial, poolXp, 3)) {
                        flatReduction += 200;
                    }
                    break;
                case CONSTANTS.skill.Agility:
                    percentReduction += 3 * Math.floor(convertXpToLvl(masteryXp) / 10);
                    break;
            }
            // apply modifiers
            adjustedInterval *= 1 - percentReduction / 100;
            adjustedInterval -= flatReduction;
            adjustedInterval = Math.ceil(adjustedInterval);
            return Math.max(250, adjustedInterval);
        }

        // Adjust interval based on down time
        // This only applies to Mining, Thieving and Agility
        function intervalRespawnAdjustment(initial, currentInterval, poolXp, masteryXp, agiLapTime) {
            let adjustedInterval = currentInterval;
            switch (initial.skillID) {
                case CONSTANTS.skill.Mining:
                    // compute max rock HP
                    let rockHP = 5 /*base*/ + convertXpToLvl(masteryXp);
                    if (poolReached(initial, poolXp, 3)) {
                        rockHP += 10;
                    }
                    rockHP += playerModifiers.increasedMiningNodeHP - playerModifiers.decreasedMiningNodeHP;
                    // potions can preserve rock HP
                    let noDamageChance = playerModifiers.increasedChanceNoDamageMining - playerModifiers.decreasedChanceNoDamageMining;
                    if (noDamageChance >= 100) {
                        break;
                    }
                    rockHP /= (1 - noDamageChance / 100);
                    // compute average time per action
                    let spawnTime = miningData[initial.currentAction].respawnInterval;
                    if (poolReached(initial, poolXp, 1)) {
                        spawnTime *= 0.9;
                    }
                    adjustedInterval = (adjustedInterval * rockHP + spawnTime) / rockHP;
                    break;

                case CONSTANTS.skill.Thieving:
                    let successRate = 0;
                    let npc = thievingNPC[initial.currentAction];
                    if (convertXpToLvl(masteryXp) >= 99) {
                        successRate = 100;
                    } else {
                        let increasedSuccess = 0;
                        if (poolReached(initial, poolXp, 1)) {
                            increasedSuccess = 10;
                        }
                        successRate = Math.floor((skillLevel[CONSTANTS.skill.Thieving] - npc.level) * 0.7
                            + convertXpToLvl(masteryXp) * 0.25
                            + npc.baseSuccess) + increasedSuccess;
                    }
                    if (successRate > npc.maxSuccess && convertXpToLvl(masteryXp) < 99) {
                        successRate = npc.maxSuccess;
                    }
                    if (glovesTracker[CONSTANTS.shop.gloves.Thieving_Gloves].isActive
                        && glovesTracker[CONSTANTS.shop.gloves.Thieving_Gloves].remainingActions > 0 // TODO: handle charge use
                        && equippedItems[CONSTANTS.equipmentSlot.Gloves] === CONSTANTS.item.Thieving_Gloves) {
                        successRate += 10;
                    }
                    successRate = Math.min(100, successRate) / 100;
                    // stunTime = 3s + time of the failed action, since failure gives no xp or mxp
                    let stunTime = 3000 + adjustedInterval;
                    // compute average time per action
                    adjustedInterval = adjustedInterval * successRate + stunTime * (1 - successRate);
                    break;

                case CONSTANTS.skill.Agility:
                    adjustedInterval = agiLapTime;
            }
            return Math.ceil(adjustedInterval);
        }

        // Adjust skill Xp based on unlocked bonuses
        function skillXpAdjustment(initial, itemXp, itemID, poolXp, masteryXp) {
            let staticXpBonus = initial.staticXpBonus;
            switch (initial.skillID) {
                case CONSTANTS.skill.Herblore:
                    if (poolReached(initial, poolXp, 1)) {
                        staticXpBonus += 0.03;
                    }
                    break;
            }
            let xpMultiplier = 1;
            switch (initial.skillID) {
                case CONSTANTS.skill.Runecrafting:
                    if (poolReached(initial, poolXp, 1) && items[itemID].type === "Rune") {
                        xpMultiplier += 1.5;
                    }
                    break;

                case CONSTANTS.skill.Cooking: {
                    const burnChance = calcBurnChance(masteryXp);
                    const cookXp = itemXp * (1 - burnChance);
                    const burnXp = 1 * burnChance;
                    itemXp = cookXp + burnXp;
                    break;
                }

                case CONSTANTS.skill.Fishing: {
                    const junkChance = calcJunkChance(initial, masteryXp, poolXp);
                    const fishXp = itemXp * (1 - junkChance);
                    const junkXp = 1 * junkChance;
                    itemXp = (fishXp + junkXp);
                    break;
                }

                case CONSTANTS.skill.Smithing: {
                    if (glovesTracker[CONSTANTS.shop.gloves.Smithing_Gloves].isActive
                        && glovesTracker[CONSTANTS.shop.gloves.Smithing_Gloves].remainingActions > 0 // TODO: handle charge use
                        && equippedItems[CONSTANTS.equipmentSlot.Gloves] === CONSTANTS.item.Smithing_Gloves) {
                        xpMultiplier += 0.5;
                    }
                    break;
                }

                case CONSTANTS.skill.Summoning: {
                    if (ETASettings.USE_TABLETS) {
                        const qty = calcSummoningTabletQty(initial, poolXp, convertXpToLvl(masteryXp));
                        itemXp += qty * initial.useTabletXp;
                    }
                }
            }
            return itemXp * staticXpBonus * xpMultiplier;
        }

        // Calculate total number of unlocked items for skill based on current skill level
        ETA.msLevelMap = {};

        function calcTotalUnlockedItems(skillID, skillXp) {
            const currentSkillLevel = convertXpToLvl(skillXp);
            if (ETA.msLevelMap[skillID] === undefined) {
                ETA.msLevelMap[skillID] = MILESTONES[skillName[skillID]].map(x => x.level)
            }
            return binarySearch(ETA.msLevelMap[skillID], (t) => currentSkillLevel < t);
        }

        // compute average actions per mastery token
        function actionsPerToken(skillID, skillXp, masteryXp) {
            let actions = 20000 / calcTotalUnlockedItems(skillID, skillXp);
            if (equippedItems.includes(CONSTANTS.item.Clue_Chasers_Insignia)) {
                actions *= ETA.insigniaModifier;
            }
            if (skillID === CONSTANTS.skill.Cooking) {
                actions /= 1 - calcBurnChance(masteryXp);
            }
            return actions;
        }

        function isGathering(skillID) {
            return [
                CONSTANTS.skill.Woodcutting,
                CONSTANTS.skill.Fishing,
                CONSTANTS.skill.Mining,
                CONSTANTS.skill.Thieving,
                CONSTANTS.skill.Agility
            ].includes(skillID);
        }

        function initialVariables(skillID, checkTaskComplete) {
            let initial = {
                skillID: skillID,
                checkTaskComplete: checkTaskComplete,
                staticXpBonus: 1,
                flatIntervalReduction: 0,
                percentIntervalReduction: 0,
                skillReq: [], // Needed items for craft and their quantities
                itemQty: {}, // Initial amount of resources
                hasMastery: skillID !== CONSTANTS.skill.Magic, // magic has no mastery, so we often check this
                multiple: ETA.SINGLE,
                completionCape: equippedItems.includes(CONSTANTS.item.Cape_of_Completion),
                // gathering skills are treated differently, so we often check this
                isGathering: isGathering(skillID),
                // Generate default values for script
                // skill
                skillXp: skillXP[skillID],
                targetLevel: ETASettings.getTargetLevel(skillID, skillLevel[skillID]),
                skillLim: [], // Xp needed to reach next level
                skillLimLevel: [],
                // mastery
                masteryLim: [], // Xp needed to reach next level
                masteryLimLevel: [0],
                totalMasteryLevel: 0,
                // pool
                poolXp: 0,
                targetPool: 0,
                targetPoolXp: 0,
                poolLim: [], // Xp need to reach next pool checkpoint
                staticPreservation: 0,
                maxPoolXp: 0,
                tokens: 0,
                poolLimCheckpoints: [10, 25, 50, 95, 100, Infinity], //Breakpoints for mastery pool bonuses followed by Infinity
                //////////////
                //DEPRECATED//
                //////////////
                masteryID: 0,
                masteryXp: 0,
                skillInterval: 0,
                itemID: undefined,
                itemXp: 0,
            }
            // skill
            initial.targetXp = convertLvlToXp(initial.targetLevel);
            // Breakpoints for skill bonuses - default all levels starting at 2 to 99, followed by Infinity
            initial.skillLimLevel = Array.from({length: 98}, (_, i) => i + 2);
            initial.skillLimLevel.push(Infinity);
            // mastery
            // Breakpoints for mastery bonuses - default all levels starting at 2 to 99, followed by Infinity
            if (initial.hasMastery) {
                initial.masteryLimLevel = Array.from({length: 98}, (_, i) => i + 2);
            }
            initial.masteryLimLevel.push(Infinity);
            // static preservation
            initial.staticPreservation = playerModifiers.increasedGlobalPreservationChance;
            initial.staticPreservation -= playerModifiers.decreasedGlobalPreservationChance;
            initial.staticPreservation += getTotalFromModifierArray("increasedSkillPreservationChance", skillID);
            initial.staticPreservation -= getTotalFromModifierArray("decreasedSkillPreservationChance", skillID)
            if (equippedItems.includes(CONSTANTS.item.Crown_of_Rhaelyx) && initial.hasMastery && !initial.isGathering) {
                initial.staticPreservation += items[CONSTANTS.item.Crown_of_Rhaelyx].baseChanceToPreserve; // Add base 10% chance
            }
            return initial;
        }

        function skillCapeEquipped(capeID) {
            return equippedItems.includes(capeID)
                || equippedItems.includes(CONSTANTS.item.Max_Skillcape)
                || equippedItems.includes(CONSTANTS.item.Cape_of_Completion);
        }

        function configureSmithing(initial) {
            initial.itemID = smithingItems[initial.currentAction].itemID;
            initial.itemXp = items[initial.itemID].smithingXP;
            initial.skillInterval = 2000;
            for (let i of items[initial.itemID].smithReq) {
                const req = {...i};
                if (req.id === CONSTANTS.item.Coal_Ore && skillCapeEquipped(CONSTANTS.item.Smithing_Skillcape)) {
                    req.qty /= 2;
                }
                initial.skillReq.push(req);
            }
            initial.masteryLimLevel = [20, 40, 60, 80, 99, Infinity]; // Smithing Mastery Limits
            return initial;
        }

        function configureFletching(initial) {
            initial.itemID = fletchingItems[initial.currentAction].itemID;
            initial.itemXp = items[initial.itemID].fletchingXP;
            initial.skillInterval = 2000;
            for (let i of items[initial.itemID].fletchReq) {
                initial.skillReq.push(i);
            }
            //Special Case for Arrow Shafts
            if (initial.itemID === CONSTANTS.item.Arrow_Shafts) {
                if (selectedFletchLog === undefined) {
                    selectedFletchLog = 0;
                }
                initial.skillReq = [initial.skillReq[selectedFletchLog]];
            }
            return initial;
        }

        function configureRunecrafting(initial) {
            initial.itemID = runecraftingItems[initial.currentAction].itemID;
            initial.itemXp = items[initial.itemID].runecraftingXP;
            initial.skillInterval = 2000;
            for (let i of items[initial.itemID].runecraftReq) {
                initial.skillReq.push(i);
            }
            initial.masteryLimLevel = [99, Infinity]; // Runecrafting has no Mastery bonus
            return initial;
        }

        function configureCrafting(initial) {
            initial.itemID = craftingItems[initial.currentAction].itemID;
            initial.itemXp = items[initial.itemID].craftingXP;
            initial.skillInterval = 3000;
            items[initial.itemID].craftReq.forEach(i => initial.skillReq.push(i));
            return initial;
        }

        function configureHerblore(initial) {
            initial.itemID = herbloreItemData[initial.currentAction].itemID[getHerbloreTier(initial.currentAction)];
            initial.itemXp = herbloreItemData[initial.currentAction].herbloreXP;
            initial.skillInterval = 2000;
            for (let i of items[initial.itemID].herbloreReq) {
                initial.skillReq.push(i);
            }
            return initial;
        }

        function configureCooking(initial) {
            initial.itemID = initial.currentAction;
            initial.itemXp = items[initial.itemID].cookingXP;
            initial.skillInterval = 3000;
            initial.skillReq = [{id: initial.itemID, qty: 1}];
            initial.masteryLimLevel = [99, Infinity]; //Cooking has no Mastery bonus
            initial.itemID = items[initial.itemID].cookedItemID;
            return initial;
        }

        function configureFiremaking(initial) {
            initial.itemID = initial.currentAction;
            initial.itemXp = logsData[initial.currentAction].xp * (1 + bonfireBonus / 100);
            initial.skillInterval = logsData[initial.currentAction].interval;
            initial.skillReq = [{id: initial.itemID, qty: 1}];
            return initial;
        }

        function configureSummoning(initial) {
            initial.itemID = summoningItems[initial.currentAction].itemID;
            initial.itemXp = getBaseSummoningXP(initial.currentAction);
            initial.useTabletXp = getBaseSummoningXP(initial.currentAction, true);
            initial.skillInterval = 5000;
            initial.recipeID = summoningData.defaultRecipe[items[initial.itemID].masteryID[1]];
            // costs can change with increasing pool / mastery
            initial.skillReq = items[initial.itemID].summoningReq[initial.recipeID].map((r, i) => {
                return {
                    id: r.id,
                    qty: getSummoningRecipeQty(initial.itemID, initial.recipeID, i),
                };
            });
            // add xp of owned tablets to initial xp
            if (ETASettings.USE_TABLETS) {
                const qty = getQtyOfItem(initial.itemID);
                initial.skillXp += qty * initial.useTabletXp;
                initial.targetSkillReached = initial.skillXp >= initial.targetXp;
            }
            initial.chanceToDouble = calculateChanceToDouble(CONSTANTS.skill.Summoning, false, 0, 0, items[initial.itemID]);
            return initial;
        }

        function configureMagic(initial) {
            initial.skillInterval = 2000;
            //Find need runes for spell
            if (ALTMAGIC[initial.currentAction].runesRequiredAlt !== undefined && useCombinationRunes) {
                for (let i of ALTMAGIC[initial.currentAction].runesRequiredAlt) {
                    initial.skillReq.push({...i});
                }
            } else {
                for (let i of ALTMAGIC[initial.currentAction].runesRequired) {
                    initial.skillReq.push({...i});
                }
            }
            // Get Rune discount
            let capeMultiplier = 1;
            if (skillCapeEquipped(CONSTANTS.item.Magic_Skillcape)) {
                // Add cape multiplier
                capeMultiplier = 2;
            }
            for (let i = 0; i < initial.skillReq.length; i++) {
                if (items[equippedItems[CONSTANTS.equipmentSlot.Weapon]].providesRune !== undefined) {
                    if (items[equippedItems[CONSTANTS.equipmentSlot.Weapon]].providesRune.includes(initial.skillReq[i].id)) {
                        initial.skillReq[i].qty -= items[equippedItems[CONSTANTS.equipmentSlot.Weapon]].providesRuneQty * capeMultiplier;
                    }
                }
            }
            initial.skillReq = initial.skillReq.filter(item => item.qty > 0); // Remove all runes with 0 cost
            //Other items
            if (ALTMAGIC[initial.currentAction].selectItem === 1 && selectedMagicItem[1] !== null) { // Spells that just use 1 item
                let found = false;
                for (const req of initial.skillReq) {
                    if (req.id === selectedMagicItem[1]) {
                        req.qty++;
                        found = true;
                    }
                }
                if (!found) {
                    initial.skillReq.push({id: selectedMagicItem[1], qty: 1});
                }
            } else if (ALTMAGIC[initial.currentAction].selectItem === -1) { // Spells that doesn't require you to select an item
                if (ALTMAGIC[initial.currentAction].needCoal) { // Rags to Riches II
                    initial.skillReq.push({id: 48, qty: 1});
                }
            } else if (selectedMagicItem[0] !== null && ALTMAGIC[initial.currentAction].selectItem === 0) { // SUPERHEAT
                for (let i of items[selectedMagicItem[0]].smithReq) {
                    initial.skillReq.push({...i});
                }
                if (ALTMAGIC[initial.currentAction].ignoreCoal) {
                    initial.skillReq = initial.skillReq.filter(item => item.id !== 48);
                }
            }
            initial.masteryLimLevel = [Infinity]; //AltMagic has no Mastery bonus
            initial.itemXp = ALTMAGIC[initial.currentAction].magicXP;
            return initial;
        }

        function configureGathering(initial) {
            initial.skillReq = [];
            initial.masteryID = initial.currentAction;
            return initial;
        }

        function configureMining(initial) {
            initial.itemID = miningData[initial.currentAction].ore;
            initial.itemXp = items[initial.itemID].miningXP;
            initial.skillInterval = 3000;
            return configureGathering(initial);
        }

        function configureThieving(initial) {
            initial.itemID = undefined;
            initial.itemXp = thievingNPC[initial.currentAction].xp;
            initial.skillInterval = 3000;
            return configureGathering(initial);
        }

        function configureWoodcutting(initial) {
            const wcAction = x => {
                return {
                    itemID: x,
                    itemXp: trees[x].xp,
                    skillInterval: trees[x].interval,
                    masteryID: x,
                };
            }
            if (!isNaN(initial.currentAction)) {
                initial.actions = [wcAction(initial.currentAction)];
            } else {
                initial.actions = initial.currentAction.map(x => wcAction(x));
            }
            return configureGathering(initial);
        }

        function configureFishing(initial) {
            initial.itemID = fishingItems[fishingAreas[initial.currentAction].fish[initial.fishID]].itemID;
            initial.itemXp = items[initial.itemID].fishingXP;
            // base avg interval
            let avgRoll = 0.5;
            const max = items[initial.itemID].maxFishingInterval;
            const min = items[initial.itemID].minFishingInterval;
            initial.skillInterval = Math.floor(avgRoll * (max - min)) + min;
            initial = configureGathering(initial);
            // correctly set masteryID
            initial.masteryID = fishingAreas[initial.currentAction].fish[initial.fishID];
            return initial
        }

        function configureAgility(initial) {
            const agiAction = x => {
                return {
                    itemXp: agilityObstacles[x].completionBonuses.xp,
                    skillInterval: agilityObstacles[x].interval,
                    masteryID: x,
                };
            }
            if (!isNaN(initial.currentAction)) {
                initial.actions = [agiAction(initial.currentAction)];
            } else {
                initial.actions = initial.currentAction.map(x => agiAction(x));
            }
            return configureGathering(initial);
        }

        function calcSummoningRecipeQty(initial, poolXp, recipeItemIndex, masteryLevel) {
            const recipe = items[initial.itemID].summoningReq[initial.recipeID][recipeItemIndex];

            // gp cost or sc cost
            if (recipe.id === -4 || recipe.id === -5) {
                return Math.max(1, SUMMONING.Settings.recipeGPCost);
            }

            // non-shard item cost
            if (items[recipe.id].type !== undefined && items[recipe.id].type !== "Shard") {
                let recipeGPCostReduction = Math.floor(masteryLevel / 10);
                const recipeGPCost = SUMMONING.Settings.recipeGPCost * (1 - (recipeGPCostReduction * 5) / 100);
                return Math.max(1, Math.floor(recipeGPCost / items[recipe.id].sellsFor));
            }

            // shard cost
            let qty = recipe.qty;
            // shard cost
            // mastery shard reduction
            if (masteryLevel >= 50) {
                qty--;
            }
            if (masteryLevel >= 99) {
                qty--;
            }
            // pool shard reduction
            if (poolReached(initial, poolXp, 1) && items[initial.itemID].summoningTier <= 2) {
                qty--;
            } else if (poolReached(initial, poolXp, 3) && items[initial.itemID].summoningTier === 3) {
                qty--;
            }
            // modifier shard reduction
            qty -= playerModifiers.decreasedSummoningShardCost - playerModifiers.increasedSummoningShardCost;
            return Math.max(1, qty);
        }

        function calcSummoningTabletQty(initial, poolXp, masteryLevel) {
            let qty = 25;
            if (poolReached(initial, poolXp, 3)) {
                qty += 10;
            }
            if (masteryLevel >= 99) {
                qty += 10;
            }
            return qty * (1 + initial.chanceToDouble / 100);
        }

        // Calculate mastery xp based on unlocked bonuses
        function calcMasteryXpToAdd(initial, totalMasteryLevel, skillXp, masteryXp, poolXp, timePerAction, itemID) {
            const modifiedTimePerAction = getTimePerActionModifierMastery(initial.skillID, timePerAction, itemID);
            let xpModifier = initial.staticMXpBonus;
            // General Mastery Xp formula
            let xpToAdd = ((calcTotalUnlockedItems(initial.skillID, skillXp) * totalMasteryLevel) / getTotalMasteryLevelForSkill(initial.skillID) + convertXpToLvl(masteryXp) * (getTotalItemsInSkill(initial.skillID) / 10)) * (modifiedTimePerAction / 1000) / 2;
            // Skill specific mastery pool modifier
            if (poolReached(initial, poolXp, 0)) {
                xpModifier += 0.05;
            }
            // Firemaking pool and log modifiers
            if (initial.skillID === CONSTANTS.skill.Firemaking) {
                // If current skill is Firemaking, we need to apply mastery progression from actions and use updated poolXp values
                if (poolReached(initial, poolXp, 3)) {
                    xpModifier += 0.05;
                }
                for (let i = 0; i < MASTERY[CONSTANTS.skill.Firemaking].xp.length; i++) {
                    // The logs you are not burning
                    if (initial.actions[0].masteryID !== i) {
                        if (getMasteryLevel(CONSTANTS.skill.Firemaking, i) >= 99) {
                            xpModifier += 0.0025;
                        }
                    }
                }
                // The log you are burning
                if (convertXpToLvl(masteryXp) >= 99) {
                    xpModifier += 0.0025;
                }
            } else {
                // For all other skills, you use the game function to grab your FM mastery progression
                if (getMasteryPoolProgress(CONSTANTS.skill.Firemaking) >= masteryCheckpoints[3]) {
                    xpModifier += 0.05;
                }
                for (let i = 0; i < MASTERY[CONSTANTS.skill.Firemaking].xp.length; i++) {
                    if (getMasteryLevel(CONSTANTS.skill.Firemaking, i) >= 99) {
                        xpModifier += 0.0025;
                    }
                }
            }
            // Combine base and modifiers
            xpToAdd *= xpModifier;
            // minimum 1 mastery xp per action
            if (xpToAdd < 1) {
                xpToAdd = 1;
            }
            // BurnChance affects average mastery Xp
            if (initial.skillID === CONSTANTS.skill.Cooking) {
                let burnChance = calcBurnChance(masteryXp);
                xpToAdd *= (1 - burnChance);
            }
            // Fishing junk gives no mastery xp
            if (initial.skillID === CONSTANTS.skill.Fishing) {
                let junkChance = calcJunkChance(initial, masteryXp, poolXp);
                xpToAdd *= (1 - junkChance);
            }
            // return average mastery xp per action
            return xpToAdd;
        }

        // Calculate pool Xp based on mastery Xp
        function calcPoolXpToAdd(skillXp, masteryXp) {
            if (convertXpToLvl(skillXp) >= 99) {
                return masteryXp / 2;
            } else {
                return masteryXp / 4;
            }
        }

        // Calculate burn chance based on mastery level
        function calcBurnChance(masteryXp) {
            let burnChance = 0;
            if (skillCapeEquipped(CONSTANTS.item.Cooking_Skillcape)) {
                return burnChance;
            }
            if (equippedItems.includes(CONSTANTS.item.Cooking_Gloves)) {
                return burnChance;
            }
            let primaryBurnChance = (30 - convertXpToLvl(masteryXp) * 0.6) / 100;
            let secondaryBurnChance = 0.01;
            if (primaryBurnChance <= 0) {
                return secondaryBurnChance;
            }
            burnChance = 1 - (1 - primaryBurnChance) * (1 - secondaryBurnChance);
            return burnChance;
        }

        // calculate junk chance
        function calcJunkChance(initial, masteryXp, poolXp) {
            // base
            let junkChance = fishingAreas[initial.currentAction].junkChance;
            // mastery turns 3% junk in 3% special
            let masteryLevel = convertXpToLvl(masteryXp);
            if (masteryLevel >= 50) {
                junkChance -= 3;
            }
            // no junk if mastery level > 65 or pool > 25%
            if (masteryLevel >= 65
                || junkChance < 0
                || poolReached(initial, poolXp, 1)) {
                junkChance = 0;
            }
            return junkChance / 100;
        }

        function perAction(masteryXp, targetMasteryXp) {
            return {
                // mastery
                masteryXp: masteryXp,
                targetMasteryReached: masteryXp >= targetMasteryXp,
                targetMasteryTime: 0,
                targetMasteryResources: {},
                // estimated number of actions taken so far
                actions: 0,
            }
        }

        function currentVariables(initial) {
            let current = {
                actionCount: 0,
                activeTotalTime: 0,
                sumTotalTime: 0,
                // skill
                skillXp: initial.skillXp,
                targetSkillReached: initial.skillXp >= initial.targetXp,
                targetSkillTime: 0,
                targetSkillResources: {},
                // pool
                poolXp: initial.poolXp,
                targetPoolReached: initial.poolXp >= initial.targetPoolXp,
                targetPoolTime: 0,
                targetPoolResources: {},
                totalMasteryLevel: initial.totalMasteryLevel,
                // items
                chargeUses: 0, // estimated remaining charge uses
                tokens: initial.tokens,
                // stats per action
                actions: initial.actions.map(x => perAction(x.masteryXp, x.targetMasteryXp)),
                // available resources
                itemQty: {...initial.itemQty},
                skillReqMap: {...initial.skillReqMap},
                used: {},
            };
            for (let id in current.itemQty) {
                current.used[id] = 0;
            }
            // Check for Crown of Rhaelyx
            if (equippedItems.includes(CONSTANTS.item.Crown_of_Rhaelyx) && initial.hasMastery && !initial.isGathering) {
                let rhaelyxCharge = getQtyOfItem(CONSTANTS.item.Charge_Stone_of_Rhaelyx);
                current.chargeUses = rhaelyxCharge * 1000; // average crafts per Rhaelyx Charge Stone
            }
            return current;
        }

        function gainPerAction(initial, current, averageActionTime) {
            return current.actions.map((x, i) => {
                const gain = {
                    xpPerAction: skillXpAdjustment(initial, initial.actions[i].itemXp, initial.actions[i].itemID, current.poolXp, x.masteryXp),
                    masteryXpPerAction: 0,
                    poolXpPerAction: 0,
                    tokensPerAction: 0,
                    tokenXpPerAction: 0,
                };

                if (initial.hasMastery) {
                    gain.masteryXpPerAction = calcMasteryXpToAdd(initial, current.totalMasteryLevel, current.skillXp, x.masteryXp, current.poolXp, averageActionTime[i], initial.actions[i].itemID);
                    gain.poolXpPerAction = calcPoolXpToAdd(current.skillXp, gain.masteryXpPerAction);
                    gain.tokensPerAction = 1 / actionsPerToken(initial.skillID, current.skillXp, x.masteryXp);
                    gain.tokenXpPerAction = initial.maxPoolXp / 1000 * gain.tokensPerAction;
                }
                return gain;
            });
        }

        // Actions until limit
        function getLim(lims, xp, max) {
            const lim = lims.find(element => element > xp);
            if (xp < max && max < lim) {
                return Math.ceil(max);
            }
            return Math.ceil(lim);
        }

        function actionsToBreakpoint(initial, current, noResources = false) {
            // Adjustments
            const currentIntervals = current.actions.map((x, i) => intervalAdjustment(initial, current.poolXp, x.masteryXp, initial.actions[i].skillInterval));
            if (initial.skillID === CONSTANTS.skill.Agility) {
                current.agiLapTime = currentIntervals.reduce((a, b) => a + b, 0);
            }
            const averageActionTimes = current.actions.map((x, i) => intervalRespawnAdjustment(initial, currentIntervals[i], current.poolXp, x.masteryXp, current.agiLapTime));
            // Current Xp
            let gains = gainPerAction(initial, current, currentIntervals);

            // average gains
            const avgXpPerS = gains.map((x, i) => x.xpPerAction / averageActionTimes[i] * 1000).reduce((a, b) => a + b, 0);
            let avgPoolPerS = gains.map((x, i) => x.poolXpPerAction / averageActionTimes[i] * 1000).reduce((a, b) => a + b, 0);
            const masteryPerS = gains.map((x, i) => x.masteryXpPerAction / averageActionTimes[i] * 1000);
            const avgTokenXpPerS = gains.map((x, i) => x.tokenXpPerAction / averageActionTimes[i] * 1000).reduce((a, b) => a + b, 0);
            const avgTokensPerS = gains.map((x, i) => x.tokensPerAction / averageActionTimes[i] * 1000).reduce((a, b) => a + b, 0);
            // TODO rescale sequential gains ?

            // get time to next breakpoint
            // skill
            const skillXpToLimit = getLim(initial.skillLim, current.skillXp, initial.targetXp) - current.skillXp;
            const skillXpSeconds = skillXpToLimit / avgXpPerS;
            // mastery
            let masteryXpSeconds = Infinity;
            const allMasteryXpSeconds = [];
            if (initial.hasMastery) {
                initial.actions.forEach((x, i) => {
                    const masteryXpToLimit = getLim(initial.skillLim, current.actions[i].masteryXp, x.targetMasteryXp) - current.actions[i].masteryXp;
                    allMasteryXpSeconds.push(masteryXpToLimit / masteryPerS[i]);
                });
                masteryXpSeconds = Math.min(...allMasteryXpSeconds);
            }
            // pool
            let poolXpSeconds = Infinity;
            if (initial.hasMastery) {
                const poolXpToLimit = getLim(initial.poolLim, current.poolXp, initial.targetPoolXp) - current.poolXp;
                poolXpSeconds = poolXpToLimit / avgPoolPerS;
            }
            // resources
            let resourceSeconds = Infinity;
            const rawPreservation = masteryPreservation(initial, current.actions[0].masteryXp, current.poolXp) / 100;
            const totalChanceToUse = Math.min(1, 1 - rawPreservation);
            const totalChanceToUseWithCharges = Math.min(1, Math.max(0.2, 1 - rawPreservation - ETA.rhaelyxChargePreservation));
            // update summoning costs
            if (initial.skillID === CONSTANTS.skill.Summoning) {
                const masteryLevel = convertXpToLvl(current.actions[0].masteryXp);
                initial.skillReq.map((_, i) =>
                    calcSummoningRecipeQty(initial, current.poolXp, i, masteryLevel)
                ).forEach(x => {
                    current.skillReqMap[x.id] = x.qty;
                })
            }
            // estimate actions remaining with current resources
            if (!noResources) {
                if (initial.actions.length > 1) {
                    ETA.log('Attempting to simulate multiple different production actions at once, this is not implemented!')
                }
                // estimate amount of actions possible with remaining resources
                // number of actions with rhaelyx charges
                const actionsWithCharge = Math.min(
                    current.chargeUses,
                    ...Object.getOwnPropertyNames(current.itemQty).map(id =>
                        current.itemQty[id] / current.skillReqMap[id] / totalChanceToUseWithCharges
                    ),
                );
                // remaining resources
                const resWithoutCharge = Math.max(
                    0,
                    Math.min(...Object.getOwnPropertyNames(current.itemQty).map(id =>
                        current.itemQty[id] / current.skillReqMap[id] - current.chargeUses * totalChanceToUseWithCharges
                    )),
                );
                const actionsWithoutCharge = resWithoutCharge / totalChanceToUse
                // add number of actions without rhaelyx charges
                const resourceActions = Math.ceil(actionsWithCharge + actionsWithoutCharge);
                resourceSeconds = resourceActions * averageActionTimes[0] / 1000;
            }

            // Minimum actions based on limits
            const rawExpectedS = Math.min(skillXpSeconds, masteryXpSeconds, poolXpSeconds, resourceSeconds)
            const expectedMS = Math.ceil(1000 * rawExpectedS);
            const expectedS = expectedMS / 1000;
            const expectedActions = averageActionTimes.map(x => expectedMS / x);
            // estimate total remaining actions
            if (!noResources) {
                current.actionCount += expectedActions[0];
            }

            // add token xp to pool xp if desired
            if (ETASettings.USE_TOKENS) {
                avgPoolPerS += avgTokenXpPerS;
            }

            // Take away resources based on expectedActions
            if (!initial.isGathering) {
                // Update remaining Rhaelyx Charge uses
                current.chargeUses -= expectedActions[0];
                if (current.chargeUses < 0) {
                    current.chargeUses = 0;
                }
                // Update remaining resources
                let resUsed;
                if (expectedActions[0] < current.chargeUses) {
                    // won't run out of charges yet
                    resUsed = expectedActions[0] * totalChanceToUseWithCharges;
                } else {
                    // first use charges
                    resUsed = current.chargeUses * totalChanceToUseWithCharges;
                    // remaining actions are without charges
                    resUsed += (expectedActions[0] - current.chargeUses) * totalChanceToUse;
                }
                for (let id in current.itemQty) {
                    const qty = Math.ceil(resUsed * current.skillReqMap[id]);
                    current.itemQty[id] -= qty;
                    current.used[id] += qty;
                }
            }

            // time for current iteration
            // gain tokens, unless we're using them
            if (!ETASettings.USE_TOKENS) {
                current.tokens += avgTokensPerS * expectedS;
            }
            // Update time and Xp
            switch (initial.multiple) {
                // active total time is number of actions * action time, number of actions is time spent / (action time + "respawn")
                case ETA.SINGLE:
                    current.activeTotalTime += expectedMS / averageActionTimes[0] * currentIntervals[0];
                    break;

                case ETA.PARALLEL:
                case ETA.SEQUENTIAL:
                    current.activeTotalTime += expectedMS
                        / averageActionTimes.reduce((a, b) => (a + b), 0)
                        * currentIntervals.reduce((a, b) => (a + b), 0);
                    break;
            }
            current.sumTotalTime += expectedMS;
            current.skillXp += avgXpPerS * expectedS;
            current.actions.forEach((x, i) => current.actions[i].masteryXp += gains[i].masteryXpPerAction * expectedActions[i]);
            current.poolXp += avgPoolPerS * expectedS;
            // Time for target skill level, 99 mastery, and 100% pool
            if (!current.targetSkillReached && initial.targetXp <= current.skillXp) {
                current.targetSkillTime = current.sumTotalTime;
                current.targetSkillReached = true;
                current.targetSkillResources = {...current.used};
            }
            current.actions.forEach((x, i) => {
                if (!x.targetMasteryReached && initial.actions[i].targetMasteryXp <= x.masteryXp) {
                    x.targetMasteryTime = current.sumTotalTime;
                    x.targetMasteryReached = true;
                    x.targetMasteryResources = {...current.used};
                }
            });
            if (!current.targetPoolReached && initial.targetPoolXp <= current.poolXp) {
                current.targetPoolTime = current.sumTotalTime;
                current.targetPoolReached = true;
                current.targetPoolResources = {...current.used};
            }
            // Update total mastery level
            current.totalMasteryLevel = initial.totalMasteryLevel;
            initial.actions.forEach((x, i) => {
                const y = current.actions[i];
                const masteryLevel = convertXpToLvl(y.masteryXp);
                if (x.masteryLevel !== masteryLevel) {
                    // increase total mastery
                    current.totalMasteryLevel += masteryLevel - x.masteryLevel;
                    if (masteryLevel === 99 && x.lastMasteryLevel !== 99) {
                        halveAgilityMasteryDebuffs(initial, initial.actions[i].masteryID);
                    }
                    x.lastMasteryLevel = masteryLevel;
                }
            });
            // return updated values
            return current;
        }

        function halveAgilityMasteryDebuffs(initial, id) {
            if (initial.skillID !== CONSTANTS.skill.Agility) {
                return;
            }
            // check if we need to halve one of the debuffs
            const m = agilityObstacles[id].modifiers;
            // xp
            initial.staticXpBonus += getBuff(m, 'decreasedGlobalSkillXP', 'decreasedSkillXP') / 100 / 2;
            // mxp
            initial.staticMXpBonus += getBuff(m, 'decreasedGlobalMasteryXP', 'decreasedMasteryXP') / 100 / 2;
            // interval
            initial.percentIntervalReduction += getBuff(m, 'increasedSkillIntervalPercent') / 2;
            initial.flatIntervalReduction += getBuff(m, 'increasedSkillInterval') / 2;
        }

        function getBuff(modifier, global, specific) {
            let change = 0;
            if (global && modifier[global]) {
                change += modifier[global];
            }
            if (specific && modifier[specific]) {
                modifier[specific].forEach(x => {
                    if (x[0] === CONSTANTS.skill.Agility) {
                        change += x[1];
                    }
                });
            }
            return change;
        }

        function currentXpRates(initial) {
            let rates = {
                xpH: 0,
                masteryXpH: 0,
                poolH: 0,
                tokensH: 0,
                actionTime: 0,
                actionsH: 0,
            };
            initial.actions.forEach((x, i) => {
                const initialInterval = intervalAdjustment(initial, initial.poolXp, x.masteryXp, x.skillInterval);
                const initialAverageActionTime = intervalRespawnAdjustment(initial, initialInterval, initial.poolXp, x.masteryXp, initial.agiLapTime);
                rates.xpH += skillXpAdjustment(initial, x.itemXp, x.itemID, initial.poolXp, x.masteryXp) / initialAverageActionTime * 1000 * 3600;
                if (initial.hasMastery) {
                    // compute current mastery xp / h using the getMasteryXpToAdd from the game or the method from this script
                    // const masteryXpPerAction = getMasteryXpToAdd(initial.skillID, initial.masteryID, initialInterval);
                    const masteryXpPerAction = calcMasteryXpToAdd(initial, initial.totalMasteryLevel, initial.skillXp, x.masteryXp, initial.poolXp, initialInterval, x.itemID);
                    const masteryXpH = masteryXpPerAction / initialAverageActionTime * 1000 * 3600
                    rates.masteryXpH += masteryXpH;
                    // pool percentage per hour
                    rates.poolH += calcPoolXpToAdd(initial.skillXp, masteryXpH) / initial.maxPoolXp;
                    rates.tokensH += 3600 * 1000 / initialAverageActionTime / actionsPerToken(initial.skillID, initial.skillXp, x.masteryXp);
                }
                rates.actionTime += initialInterval;
                rates.actionsH += 3600 * 1000 / initialAverageActionTime;
            });
            if (initial.multiple === ETA.PARALLEL) {
                rates.actionTime /= initial.actions.length;
            }
            if (initial.multiple === ETA.SEQUENTIAL) {
                rates.actionsH /= initial.actions.length;
            }
            // each token contributes one thousandth of the pool and then convert to percentage
            rates.poolH = (rates.poolH + rates.tokensH / 1000) * 100;
            return rates;
        }

        function resourcesLeft(itemQty, reqMap) {
            for (let id in itemQty) {
                if (itemQty[id] < reqMap[id]) {
                    return false;
                }
            }
            return true;
        }

        function getXpRates(initial, current) {
            // compute exp rates, either current or average until resources run out
            let rates = {};
            if (ETASettings.CURRENT_RATES || initial.isGathering || !resourcesLeft(initial.itemQty, initial.skillReqMap)) {
                // compute current rates
                rates = currentXpRates(initial);
            } else {
                // compute average rates until resources run out
                rates.xpH = (current.skillXp - initial.skillXp) * 3600 * 1000 / current.sumTotalTime;
                rates.masteryXpH = initial.actions.map((x, i) => (current.actions[i].masteryXp - x.masteryXp) * 3600 * 1000 / current.sumTotalTime);
                // average pool percentage per hour
                rates.poolH = (current.poolXp - initial.poolXp) * 3600 * 1000 / current.sumTotalTime / initial.maxPoolXp;
                rates.tokensH = (current.tokens - initial.tokens) * 3600 * 1000 / current.sumTotalTime;
                rates.actionTime = current.activeTotalTime / current.actionCount;
                rates.actionsH = 3600 * 1000 / current.sumTotalTime * current.actionCount;
                // each token contributes one thousandth of the pool and then convert to percentage
                rates.poolH = (rates.poolH + rates.tokensH / 1000) * 100;
            }
            return rates;
        }

        // Calculates expected time, taking into account Mastery Level advancements during the craft
        function calcExpectedTime(initial) {
            // initialize the expected time variables
            let current = currentVariables(initial);

            // loop until out of resources
            while (!initial.isGathering && resourcesLeft(current.itemQty, current.skillReqMap)) {
                current = actionsToBreakpoint(initial, current);
            }

            // method to convert final pool xp to percentage
            const poolCap = ETASettings.UNCAP_POOL ? Infinity : 100
            const poolXpToPercentage = poolXp => Math.min((poolXp / initial.maxPoolXp) * 100, poolCap).toFixed(2);
            // create result object
            let expectedTime = {
                timeLeft: Math.round(current.sumTotalTime),
                actionCount: Math.floor(current.actionCount),
                finalSkillXp: current.skillXp,
                finalMasteryXp: current.actions.map(x => x.masteryXp),
                finalPoolXp: current.poolXp,
                finalPoolPercentage: poolXpToPercentage(current.poolXp),
                targetPoolTime: current.targetPoolTime,
                targetMasteryTime: current.actions.map(x => x.targetMasteryTime),
                targetSkillTime: current.targetSkillTime,
                rates: getXpRates(initial, current),
                tokens: current.tokens,
            };
            // continue calculations until time to all targets is found
            while (!current.targetSkillReached || (initial.hasMastery && (!current.actions.map(x => x.targetMasteryReached).reduce((a, b) => a && b, true) || !current.targetPoolReached))) {
                current = actionsToBreakpoint(initial, current, true);
            }
            // if it is a gathering skill, then set final values to the values when reaching the final target
            if (initial.isGathering) {
                expectedTime.finalSkillXp = current.skillXp;
                expectedTime.finalMasteryXp = current.actions.map(x => x.masteryXp);
                expectedTime.finalPoolXp = current.poolXp;
                expectedTime.finalPoolPercentage = poolXpToPercentage(current.poolXp);
                expectedTime.tokens = current.tokens;
            }
            // set time to targets
            expectedTime.targetSkillTime = current.targetSkillTime;
            expectedTime.targetMasteryTime = current.actions.map(x => x.targetMasteryTime);
            expectedTime.targetPoolTime = current.targetPoolTime;
            // return the resulting data object
            expectedTime.current = current;
            return expectedTime;
        }

        function setupTimeRemaining(initial) {
            // Set current skill and pull matching variables from game with script
            switch (initial.skillID) {
                case CONSTANTS.skill.Smithing:
                    initial = configureSmithing(initial);
                    break;
                case CONSTANTS.skill.Fletching:
                    initial = configureFletching(initial);
                    break;
                case CONSTANTS.skill.Runecrafting:
                    initial = configureRunecrafting(initial);
                    break;
                case CONSTANTS.skill.Crafting:
                    initial = configureCrafting(initial);
                    break;
                case CONSTANTS.skill.Herblore:
                    initial = configureHerblore(initial);
                    break;
                case CONSTANTS.skill.Cooking:
                    initial = configureCooking(initial);
                    break;
                case CONSTANTS.skill.Firemaking:
                    initial = configureFiremaking(initial);
                    break;
                case CONSTANTS.skill.Magic:
                    initial = configureMagic(initial);
                    break;
                case CONSTANTS.skill.Mining:
                    initial = configureMining(initial);
                    break;
                case CONSTANTS.skill.Thieving:
                    initial = configureThieving(initial);
                    break;
                case CONSTANTS.skill.Woodcutting:
                    initial = configureWoodcutting(initial);
                    break;
                case CONSTANTS.skill.Fishing:
                    initial = configureFishing(initial);
                    break;
                case CONSTANTS.skill.Agility:
                    initial = configureAgility(initial);
                    break;
                case CONSTANTS.skill.Summoning:
                    initial = configureSummoning(initial);
            }
            // configure interval reductions
            initial.percentIntervalReduction += getTotalFromModifierArray("decreasedSkillIntervalPercent", initial.skillID);
            initial.percentIntervalReduction -= getTotalFromModifierArray("increasedSkillIntervalPercent", initial.skillID);
            initial.flatIntervalReduction += getTotalFromModifierArray("decreasedSkillInterval", initial.skillID);
            initial.flatIntervalReduction -= getTotalFromModifierArray("increasedSkillInterval", initial.skillID);
            if (initial.skillID === CONSTANTS.skill.Agility) {
                // set initial lap time
                initial.agiLapTime = 0;
                if (initial.skillID === CONSTANTS.skill.Agility) {
                    const poolXp = MASTERY[initial.skillID].pool;
                    initial.agilityObstacles.forEach(x => {
                        const masteryXp = MASTERY[initial.skillID].xp[x];
                        const interval = agilityObstacles[x].interval;
                        initial.agiLapTime += intervalAdjustment(initial, poolXp, masteryXp, interval);
                    });
                }
            }
            // Configure initial mastery values for all skills with masteries
            if (initial.hasMastery) {
                // mastery
                initial.totalMasteryLevel = getCurrentTotalMasteryLevelForSkill(initial.skillID);
                // pool
                initial.poolXp = MASTERY[initial.skillID].pool;
                initial.maxPoolXp = getMasteryPoolTotalXP(initial.skillID);
                initial.targetPool = ETASettings.getTargetPool(initial.skillID, 100 * initial.poolXp / initial.maxPoolXp);
                initial.targetPoolXp = initial.maxPoolXp;
                if (initial.targetPool !== 100) {
                    initial.targetPoolXp = initial.maxPoolXp / 100 * initial.targetPool;
                }
                initial.tokens = getQtyOfItem(CONSTANTS.item["Mastery_Token_" + skillName[initial.skillID]])
            }

            // convert single action skills to `actions` format
            // TODO: put it in this format straight away and remove the duplication
            if (initial.actions === undefined) {
                initial.actions = [{
                    itemID: initial.itemID,
                    itemXp: initial.itemXp,
                    skillInterval: initial.skillInterval,
                    masteryID: initial.masteryID, // this might still be undefined at this point
                }];
            }

            // further configure the `actions`
            initial.actions.forEach(x => {
                if (initial.hasMastery) {
                    if (!initial.isGathering) {
                        x.masteryID = items[x.itemID].masteryID[1];
                    }
                    x.masteryXp = MASTERY[initial.skillID].xp[x.masteryID];
                    x.masteryLevel = convertXpToLvl(x.masteryXp);
                    x.lastMasteryLevel = x.masteryLevel;
                    x.targetMastery = ETASettings.getTargetMastery(initial.skillID, convertXpToLvl(x.masteryXp));
                    x.targetMasteryXp = convertLvlToXp(x.targetMastery);
                }
            });

            // Get itemXp Bonuses from gear and pets
            initial.staticXpBonus = getStaticXPBonuses(initial.skillID);
            initial.staticMXpBonus = getStaticMXPBonuses(initial.skillID);

            // Populate masteryLim from masteryLimLevel
            for (let i = 0; i < initial.masteryLimLevel.length; i++) {
                initial.masteryLim[i] = convertLvlToXp(initial.masteryLimLevel[i]);
            }
            // Populate skillLim from skillLimLevel
            for (let i = 0; i < initial.skillLimLevel.length; i++) {
                initial.skillLim[i] = convertLvlToXp(initial.skillLimLevel[i]);
            }
            // Populate poolLim from masteryCheckpoints
            for (let i = 0; i < initial.poolLimCheckpoints.length; i++) {
                initial.poolLim[i] = initial.maxPoolXp * initial.poolLimCheckpoints[i] / 100;
            }

            // Get Item Requirements and Current Requirements
            initial.skillReqMap = {};
            for (let i = 0; i < initial.skillReq.length; i++) {
                let itemQty = getQtyOfItem(initial.skillReq[i].id);
                initial.itemQty[initial.skillReq[i].id] = itemQty;
                initial.skillReqMap[initial.skillReq[i].id] = initial.skillReq[i].qty;
            }
            return initial;
        }

        function getStaticXPBonuses(skill) {
            let xpMultiplier = 1;
            xpMultiplier += getTotalFromModifierArray("increasedSkillXP", skill) / 100;
            xpMultiplier -= getTotalFromModifierArray("decreasedSkillXP", skill) / 100;
            xpMultiplier += (playerModifiers.increasedGlobalSkillXP - playerModifiers.decreasedGlobalSkillXP) / 100;
            return xpMultiplier;
        }

        function getStaticMXPBonuses(skill) {
            let xpMultiplier = 1;
            xpMultiplier += getTotalFromModifierArray("increasedMasteryXP", skill) / 100;
            xpMultiplier -= getTotalFromModifierArray("decreasedMasteryXP", skill) / 100;
            xpMultiplier += (playerModifiers.increasedGlobalMasteryXP - playerModifiers.decreasedGlobalMasteryXP) / 100;
            return xpMultiplier;
        }

        // Main function
        function timeRemaining(initial) {
            initial = setupTimeRemaining(initial);
            //Time left
            const results = calcExpectedTime(initial);
            const ms = {
                resources: Math.round(results.timeLeft),
                skill: Math.round(results.targetSkillTime),
                mastery: Math.round(results.targetMasteryTime),
                pool: Math.round(results.targetPoolTime),
            };
            //Inject timeLeft HTML
            const now = new Date();
            const timeLeftElement = injectHTML(initial, results, ms.resources, now);
            if (timeLeftElement !== null) {
                generateTooltips(initial, ms, results, timeLeftElement, now, {noMastery: initial.actions.length > 1});
            }
            if (initial.actions.length > 1) {
                const actions = [...initial.actions];
                const currentActions = [...initial.currentAction];
                actions.forEach((a, i) => {
                    initial.actions = [a];
                    initial.currentAction = currentActions[i];
                    const singleTimeLeftElement = injectHTML(initial, {rates: currentXpRates(initial)}, ms.resources, now, false);
                    if (singleTimeLeftElement !== null) {
                        const aux = {
                            finalMasteryXp: [results.finalMasteryXp[i]],
                            current: {actions: [{targetMasteryResources: {}}]},
                        }
                        generateTooltips(initial, {mastery: results.current.actions[i].targetMasteryTime}, aux, singleTimeLeftElement, now, {
                            noSkill: true,
                            noPool: true
                        });
                    }
                });
                //reset
                initial.actions = actions;
                initial.currentAction = currentActions;
            }

            // TODO fix this for woodcutting and agility
            if (initial.actions.length === 1) {
                // Set global variables to track completion
                let times = [];
                if (!initial.isGathering) {
                    times.push(ETA.time(ETASettings.DING_RESOURCES, 0, -ms.resources, "Processing finished."));
                }
                times.push(ETA.time(ETASettings.DING_LEVEL, initial.targetLevel, convertXpToLvl(initial.skillXp), "Target level reached."));
                if (initial.hasMastery) {
                    initial.actions.forEach((x, i) =>
                        times.push(ETA.time(ETASettings.DING_MASTERY, x.targetMastery, convertXpToLvl(x.masteryXp), "Target mastery reached."))
                    );
                    times.push(ETA.time(ETASettings.DING_POOL, initial.targetPool, 100 * initial.poolXp / initial.maxPoolXp, "Target pool reached."));
                }
                ETA.setTimeLeft(initial, times);
                if (initial.checkTaskComplete) {
                    ETA.taskComplete();
                }
                if (!initial.isGathering) {
                    generateProgressBars(initial, results, 0 /*TODO add proper action index here, usually it's 0 though*/);
                }
            }
        }

        function injectHTML(initial, results, msLeft, now, initialRun = true) {
            let timeLeftElementId = `timeLeft${skillName[initial.skillID]}`;
            if (initial.actions.length > 1) {
                timeLeftElementId += "-Secondary";
            } else if (initial.isGathering) {
                timeLeftElementId += "-" + initial.currentAction;
            }
            const timeLeftElement = document.getElementById(timeLeftElementId);
            if (timeLeftElement === null) {
                switch (initial.skillID) {
                    case CONSTANTS.skill.Thieving:
                        ETA.makeThievingDisplay();
                        break;
                    case CONSTANTS.skill.Agility:
                        ETA.makeAgilityDisplay();
                        break;
                }
                if (initialRun) {
                    // try running the method again
                    return injectHTML(initial, results, msLeft, now, false);
                }
                return null;
            }
            let finishedTime = addMSToDate(now, msLeft);
            timeLeftElement.textContent = "";
            if (ETASettings.SHOW_XP_RATE) {
                timeLeftElement.textContent = "Xp/h: " + formatNumber(Math.floor(results.rates.xpH));
                if (initial.hasMastery) {
                    timeLeftElement.textContent += "\r\nMXp/h: " + formatNumber(Math.floor(results.rates.masteryXpH))
                        + `\r\nPool/h: ${results.rates.poolH.toFixed(2)}%`
                }
            }
            if (ETASettings.SHOW_ACTION_TIME) {
                timeLeftElement.textContent += "\r\nAction time: " + formatNumber(Math.ceil(results.rates.actionTime) / 1000) + 's';
                timeLeftElement.textContent += "\r\nActions/h: " + formatNumber(Math.round(100 * results.rates.actionsH) / 100);
            }
            if (!initial.isGathering) {
                if (msLeft === 0) {
                    timeLeftElement.textContent += "\r\nNo resources!";
                } else {
                    timeLeftElement.textContent += "\r\nActions: " + formatNumber(results.actionCount)
                        + "\r\nTime: " + msToHms(msLeft)
                        + "\r\nETA: " + dateFormat(now, finishedTime);
                }
            }
            if (initial.actions.length === 1 && (initial.isGathering || initial.skillID === CONSTANTS.skill.Cooking)) {
                const itemID = initial.actions[0].itemID;
                if (itemID !== undefined) {
                    const youHaveElementId = timeLeftElementId + "-YouHave";
                    $("#" + youHaveElementId).replaceWith(''
                        + `<small id="${youHaveElementId}">`
                        + `<span>You have: ${formatNumber(getQtyOfItem(itemID))}</span>`
                        + `<img class="skill-icon-xs mr-2" src="${items[itemID].media}">`
                        + "</small>"
                    );
                }
            }
            timeLeftElement.style.display = "block";
            return timeLeftElement;
        }

        function generateTooltips(initial, ms, results, timeLeftElement, now, flags = {}) {
            // Generate progression Tooltips
            if (!timeLeftElement._tippy) {
                tippy(timeLeftElement, {
                    allowHTML: true,
                    interactive: false,
                    animation: false,
                });
            }
            let tooltip = '';
            // level tooltip
            if (!flags.noSkill) {
                const finalLevel = convertXpToLvl(results.finalSkillXp, true)
                const levelProgress = getPercentageInLevel(results.finalSkillXp, results.finalSkillXp, "skill");
                tooltip += finalLevelElement(
                    'Final Level',
                    formatLevel(finalLevel, levelProgress) + ' / 99',
                    'success',
                ) + tooltipSection(initial, now, ms.skill, initial.targetLevel, results.current.targetSkillResources);
            }
            // mastery tooltip
            if (!flags.noMastery && initial.hasMastery) {
                // don't show mastery target when combining multiple actions
                const finalMastery = convertXpToLvl(results.finalMasteryXp[0]);
                const masteryProgress = getPercentageInLevel(results.finalMasteryXp[0], results.finalMasteryXp[0], "mastery");
                tooltip += finalLevelElement(
                    'Final Mastery',
                    formatLevel(finalMastery, masteryProgress) + ' / 99',
                    'info',
                ) + tooltipSection(initial, now, ms.mastery, initial.actions[0].targetMastery, results.current.actions[0].targetMasteryResources);
            }
            // pool tooltip
            if (!flags.noPool && initial.hasMastery) {
                tooltip += finalLevelElement(
                    'Final Pool XP',
                    results.finalPoolPercentage + '%',
                    'warning',
                )
                let prepend = ''
                const tokens = Math.round(results.tokens);
                if (tokens > 0) {
                    prepend += `Final token count: ${tokens}`;
                    if (ms.pool > 0) {
                        prepend += '<br>';
                    }
                }
                tooltip += tooltipSection(initial, now, ms.pool, `${initial.targetPool}%`, results.current.targetPoolResources, prepend);
            }
            // wrap and return
            timeLeftElement._tippy.setContent(`<div>${tooltip}</div>`);
        }

        function tooltipSection(initial, now, ms, target, resources, prepend = '') {
            // final level and time to target level
            if (ms > 0) {
                return wrapTimeLeft(
                    prepend + timeLeftToHTML(
                    initial,
                    target,
                    msToHms(ms),
                    dateFormat(now, addMSToDate(now, ms)),
                    resources,
                    ),
                );
            } else if (prepend !== '') {
                return wrapTimeLeft(
                    prepend,
                );
            }
            return '';
        }

        function finalLevelElement(finalName, finalTarget, label) {
            return ''
                + '<div class="row no-gutters">'
                + '  <div class="col-6" style="white-space: nowrap;">'
                + '    <h3 class="font-size-base m-1" style="color:white;" >'
                + `      <span class="p-1" style="text-align:center; display: inline-block;line-height: normal;color:white;">`
                + finalName
                + '      </span>'
                + '    </h3>'
                + '  </div>'
                + '  <div class="col-6" style="white-space: nowrap;">'
                + '    <h3 class="font-size-base m-1" style="color:white;" >'
                + `      <span class="p-1 bg-${label} rounded" style="text-align:center; display: inline-block;line-height: normal;width: 100px;color:white;">`
                + finalTarget
                + '      </span>'
                + '    </h3>'
                + '  </div>'
                + '</div>';
        }

        const timeLeftToHTML = (initial, target, time, finish, resources) => `Time to ${target}: ${time}<br>ETA: ${finish}` + resourcesLeftToHTML(initial, resources);

        const resourcesLeftToHTML = (initial, resources) => {
            if (ETASettings.HIDE_REQUIRED || initial.isGathering || resources === 0) {
                return '';
            }
            let req = Object.getOwnPropertyNames(resources).map(id => {
                    let src;
                    if (id === "-5") {
                        src = "assets/media/main/slayer_coins.svg"
                    }
                    if (id === "-4") {
                        src = "assets/media/main/coins.svg"
                    }
                    if (items[id] !== undefined) {
                        src = items[id].media;
                    }
                    return `<span>${formatNumber(resources[id])}</span><img class="skill-icon-xs mr-2" src="${src}">`
                }
            ).join('');
            return `<br/>Requires: ${req}`;
        }

        const wrapTimeLeft = (s) => {
            return ''
                + '<div class="row no-gutters">'
                + '	<span class="col-12 m-1" style="padding:0.5rem 1.25rem;min-height:2.5rem;font-size:0.875rem;line-height:1.25rem;text-align:center">'
                + s
                + '	</span>'
                + '</div>';
        }

        const formatLevel = (level, progress) => {
            if (!ETASettings.SHOW_PARTIAL_LEVELS) {
                return level;
            }
            progress = Math.floor(progress);
            if (progress !== 0) {
                level = (level + progress / 100).toFixed(2);
            }
            return level;
        }

        function generateProgressBars(initial, results, idx) {
            // skill
            const skillProgress = getPercentageInLevel(initial.skillXp, results.finalSkillXp, "skill", true);
            $(`#skill-progress-bar-end-${initial.skillID}`).css("width", skillProgress + "%");
            // mastery
            if (initial.hasMastery) {
                const masteryProgress = getPercentageInLevel(initial.actions[idx].masteryXp, results.finalMasteryXp[idx], "mastery", true);
                $(`#${initial.skillID}-mastery-pool-progress-end`).css("width", masteryProgress + "%");
                // pool
                const poolProgress = (results.finalPoolPercentage > 100) ?
                    100 - ((initial.poolXp / initial.maxPoolXp) * 100) :
                    (results.finalPoolPercentage - ((initial.poolXp / initial.maxPoolXp) * 100)).toFixed(4);
                $(`#mastery-pool-progress-end-${initial.skillID}`).css("width", poolProgress + "%");
            }
        }
    }

    function loadETA() {
        // Loading script
        ETA.log('loading...');

        // constants
        ETA.SINGLE = 0;
        ETA.PARALLEL = 1;
        ETA.SEQUENTIAL = 2;

        // data
        ETA.insigniaModifier = 1 - items[CONSTANTS.item.Clue_Chasers_Insignia].increasedItemChance / 100;
        // (25 - 10) / 100 = 0.15
        ETA.rhaelyxChargePreservation = (items[CONSTANTS.item.Crown_of_Rhaelyx].chanceToPreserve - items[CONSTANTS.item.Crown_of_Rhaelyx].baseChanceToPreserve) / 100;

        // lvlToXp cache
        ETA.lvlToXp = Array.from({length: 200}, (_, i) => exp.level_to_xp(i));

        // select and start craft overrides
        ETA.selectRef = {};
        ETA.startRef = {};
        [	// skill name, select names, < start name >
            // start name is only required if the start method is not of the form `start${skill name}`
            // production skills
            ["Smithing", ["Smith"]],
            ["Fletching", ["Fletch"]],
            ["Runecrafting", ["Runecraft"]],
            ["Crafting", ["Craft"]],
            ["Herblore", ["Herblore"]],
            ["Cooking", ["Food"]],
            ["Firemaking", ["Log"], "burnLog"],
            ["Summoning", ["Summon"], "createSummon"],
            // alt magic
            ["Magic", ["Magic", "ItemForMagic"], "castMagic"],
            // gathering skills go in a the next loop
        ].forEach(skill => {
            let skillName = skill[0];
            // wrap the select methods
            let selectNames = skill[1];
            selectNames.forEach(entry => {
                let selectName = "select" + entry;
                // original methods are kept in the selectRef object
                ETA.selectRef[selectName] = window[selectName];
                window[selectName] = function (...args) {
                    ETA.selectRef[selectName](...args);
                    try {
                        ETA.timeRemainingWrapper(CONSTANTS.skill[skillName], false);
                    } catch (e) {
                        console.error(e);
                    }
                };
            });
            // wrap the start methods
            let startName = "start" + skillName;
            if (skill.length > 2) {
                // override default start name if required
                startName = skill[2];
            }
            // original methods are kept in the startRef object
            ETA.startRef[skillName] = window[startName];
            window[startName] = function (...args) {
                ETA.startRef[skillName](...args);
                try {
                    ETA.timeRemainingWrapper(CONSTANTS.skill[skillName], true);
                } catch (e) {
                    console.error(e);
                }
            };
        });
        [	// skill name, start name
            // gathering skills
            ["Mining", "mineRock"],
            ["Thieving", "pickpocket"],
            ["Woodcutting", "cutTree"],
            ["Fishing", "startFishing"],
            ["Fishing", "selectFish"],
            ["Agility", "startAgility"],
        ].forEach(skill => {
            let skillName = skill[0];
            // wrap the start method
            let startName = skill[1];
            // original methods are kept in the startRef object
            ETA.startRef[startName] = window[startName];
            window[startName] = function (...args) {
                ETA.startRef[startName](...args);
                try {
                    ETA.timeRemainingWrapper(CONSTANTS.skill[skillName], true);
                } catch (e) {
                    console.error(e);
                }
            };
        });

        ETA.changePageRef = changePage;
        changePage = function (...args) {
            let skillName = undefined;
            switch (args[0]) {
                case 0:
                    skillName = "Woodcutting";
                    break;
                case 7:
                    skillName = "Fishing";
                    break;
                case 10:
                    skillName = "Mining";
                    break;
                case 14:
                    skillName = "Thieving";
                    break;
                case 26:
                    skillName = "Agility";
            }
            if (skillName !== undefined) {
                try {
                    ETA.timeRemainingWrapper(CONSTANTS.skill[skillName], false);
                } catch (e) {
                    console.error(e);
                }
            }
            ETA.changePageRef(...args);
        };

        // Create timeLeft containers
        ETA.makeProcessingDisplays();
        ETA.makeMiningDisplay();
        ETA.makeThievingDisplay();
        ETA.makeWoodcuttingDisplay();
        ETA.makeFishingDisplay();
        ETA.makeAgilityDisplay();

        // remake Agility display after loading the Agility Obstacles
        ETA.loadAgilityRef = loadAgility;
        loadAgility = (...args) => {
            ETA.loadAgilityRef(...args);
            ETA.log('Remaking Agility display');
            ETA.makeAgilityDisplay();
            try {
                ETA.timeRemainingWrapper(CONSTANTS.skill.Agility, false);
            } catch (e) {
                console.error(e);
            }
        }

        // Mastery Pool progress
        for (let id in SKILLS) {
            if (SKILLS[id].hasMastery) {
                let bar = $(`#mastery-pool-progress-${id}`)[0];
                $(bar).after(`<div id="mastery-pool-progress-end-${id}" class="progress-bar bg-warning" role="progressbar" style="width: 0%; background-color: #e5ae679c !important;"></div>`);
            }
        }

        // Mastery Progress bars
        for (let id in SKILLS) {
            if (SKILLS[id].hasMastery) {
                let name = skillName[id].toLowerCase();
                let bar = $(`#${name}-mastery-progress`)[0];
                $(bar).after(`<div id="${id}-mastery-pool-progress-end" class="progress-bar bg-info" role="progressbar" style="width: 0%; background-color: #5cace59c !important;"></div>`);
            }
        }

        // Mastery Skill progress
        for (let id in SKILLS) {
            if (SKILLS[id].hasMastery) {
                let bar = $(`#skill-progress-bar-${id}`)[0];
                $(bar).after(`<div id="skill-progress-bar-end-${id}" class="progress-bar bg-info" role="progressbar" style="width: 0%; background-color: #5cace59c !important;"></div>`);
            }
        }
        //
        ETA.log('loaded!');
        setTimeout(ETA.createSettingsMenu, 50);

        // load settings from local storage
        if (window.localStorage['ETASettings'] !== undefined) {
            const stored = window.JSON.parse(window.localStorage['ETASettings']);
            Object.getOwnPropertyNames(stored).forEach(x => {
                window.ETASettings[x] = stored[x];
            });
            window.ETASettings.save();
        }
        // regularly save settings to local storage
        setInterval(window.ETASettings.save, 1000)
    }
}

// inject the script
(function () {
    function injectScript(main) {
        const scriptElement = document.createElement('script');
        scriptElement.textContent = `try {(${main})();} catch (e) {console.log(e);}`;
        document.body.appendChild(scriptElement).parentNode.removeChild(scriptElement);
    }

    function loadScript() {
        if ((window.isLoaded && !window.currentlyCatchingUp)
            || (typeof unsafeWindow !== 'undefined' && unsafeWindow.isLoaded && !unsafeWindow.currentlyCatchingUp)) {
            // Only load script after game has opened
            clearInterval(scriptLoader);
            injectScript(script);
        }
    }

    const scriptLoader = setInterval(loadScript, 200);
})();