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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name		Melvor ETA
// @namespace	http://tampermonkey.net/
// @version		0.11.5
// @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
// @include		https://melvoridle.com/*
// @include		https://*.melvoridle.com/*
// @exclude		https://melvoridle.com/index.php
// @exclude		https://*.melvoridle.com/index.php
// @exclude		https://wiki.melvoridle.com/*
// @exclude		https://*.wiki.melvoridle.com/*
// @inject-into page
// @noframes
// @grant		none
// ==/UserScript==

((main) => {
    const script = document.createElement('script');
    script.textContent = `try { (${main})(); } catch (e) { console.log(e); }`;
    document.body.appendChild(script).parentNode.removeChild(script);
})(() => {
    function startETASettings() {
        if (window.ETASettings === undefined) {
            createETASettings();
            // load settings from local storage
            if (window.localStorage['ETASettings'] !== undefined) {
                window.ETASettings.load();
                window.ETASettings.save();
            }
        }
    }

    function startETA() {
        if (window.ETA !== undefined) {
            ETA.error('ETA is already loaded!');
        } else {
            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: {
                // [Skills.Firemaking]: 120,
            },
            TARGET_MASTERY: {
                // [Skills.Herblore]: 90,
            },
            TARGET_POOL: {
                // [Skills.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, maxTarget) => {
                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;
                }
                if (target >= maxTarget) {
                    target = maxTarget;
                }
                return Math.ceil(target);
            },
            getTargetLevel: (skillID, currentLevel) => {
                return ETASettings.getTarget(currentLevel, ETASettings.GLOBAL_TARGET_LEVEL, ETASettings.TARGET_LEVEL[skillID], 99, 170);
            },
            getTargetMastery: (skillID, currentMastery) => {
                return ETASettings.getTarget(currentMastery, ETASettings.GLOBAL_TARGET_MASTERY, ETASettings.TARGET_MASTERY[skillID], 99, 170);
            },
            getTargetPool: (skillID, currentPool) => {
                return ETASettings.getTarget(currentPool, ETASettings.GLOBAL_TARGET_POOL, ETASettings.TARGET_POOL[skillID], 100, 100);
            },

            /*
                methods
             */
            // save settings to local storage
            save: () => {
                window.localStorage['ETASettings'] = window.JSON.stringify(window.ETASettings);
            },
            // load settings from local storage
            load: () => {
                const stored = window.JSON.parse(window.localStorage['ETASettings']);
                Object.getOwnPropertyNames(stored).forEach(x => {
                    window.ETASettings[x] = stored[x];
                });
            },
        };
    }

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

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

        ETA.error = function (...args) {
            console.error("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! Melvor ETA will work fine without it. '
                        + 'Install the "Melvor Idle Combat Simulator Reloaded" extension to use the settings interface.');
                    ETA.log('Find it here: https://github.com/visua0/Melvor-Idle-Combat-Simulator-Reloaded');
                } else {
                    // try again in 50 ms
                    setTimeout(ETA.createSettingsMenu, 50);
                }
                return;
            }

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

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

            // create wrapper
            ETA.content = document.createElement('div');
            ETA.content.className = 'mcsTabContent';

            // add toggles card
            ETA.addToggles();

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

            // add target card
            ETA.addTargetInputs();

            // create modal and access point
            ETA.modal = MICSR.addModal('ETA Settings', ETA.modalID, [ETA.content]);
            let style = document.createElement("style");
            document.head.appendChild(style);
            let sheet = style.sheet;
            sheet.insertRule('#etaModal.show { display: flex !important; }')
            sheet.insertRule('#etaModal .modal-dialog { max-width: 95%; display: inline-block; }')
            MICSR.addMenuItem('ETA Settings', 'assets/media/main/settings_header.svg', ETA.menuItemID, ETA.modalID);

            // 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('EtaTarget', true, ETA.content, '', '150px', true);
            [
                Skills.Woodcutting,
                Skills.Fishing,
                Skills.Firemaking,
                Skills.Cooking,
                Skills.Mining,
                Skills.Smithing,
                Skills.Thieving,
                Skills.Fletching,
                Skills.Crafting,
                Skills.Runecrafting,
                Skills.Herblore,
                Skills.Agility,
                Skills.Summoning,
                Skills.Astrology,
                Skills.Magic,
            ].forEach(i => {
                const card = ETA.skillTargetCard.addTab(SKILLS[i].name, SKILLS[i].media, '', '150px', false);
                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//
        //////////////

        ETA.displayContainer = (id) => {
            const displayContainer = document.createElement('div');
            displayContainer.classList = "font-size-base font-w600 text-center text-muted";
            const display = document.createElement('small');
            display.id = id;
            display.classList = 'mb-2';
            display.style = 'display:block;clear:both;white-space:pre-line';
            display.dataToggle = 'tooltip';
            display.dataPlacement = 'top';
            display.dataHtml = 'true';
            display.title = '';
            display.dataOriginalTitle = '';
            displayContainer.appendChild(display);
            const displayAmt = document.createElement('small');
            displayAmt.id = `${id + '-YouHave'}`;
            displayAmt.classList = 'mb-2';
            displayAmt.style = 'display:block;clear:both;white-space:pre-line';
            displayContainer.appendChild(displayAmt);
            return displayContainer;
        }

        ETA.displays = {};

        ETA.createDisplay = (skillID, index) => {
            let displayID = `timeLeft${Skills[skillID]}`;
            if (index !== undefined) {
                displayID += `-${index}`;
            }
            ETA.displays[displayID] = true;
            let display = document.getElementById(displayID);
            if (display !== null) {
                // display already exists
                return display;
            }
            // standard processing container
            if ([
                Skills.Smithing,
                Skills.Fletching,
                Skills.Crafting,
                Skills.Runecrafting,
                Skills.Herblore,
                Skills.Summoning
            ].includes(skillID)) {
                const node = document.querySelector(`[aria-labelledBy=${Skills[skillID]}-artisan-menu-recipe-select]`).parentElement.parentElement.parentElement
                display = node.parentNode.insertBefore(ETA.displayContainer(displayID), node.nextSibling);
                return display ? display.firstChild : undefined;
            }
            // other containers
            let node = null;
            const wrapperID = `${displayID}Wrapper`;
            let wrapper = undefined;
            switch (skillID) {
                case Skills.Woodcutting:
                    if (index === undefined) {
                        node = document.getElementsByClassName('progress-bar bg-woodcutting')[0].parentNode;
                        display = node.parentNode.insertBefore(ETA.displayContainer(displayID), node.nextSibling);
                    } else {
                        node = document.getElementsByClassName('progress-bar bg-woodcutting')[index + 1].parentNode;
                        display = node.parentNode.insertBefore(ETA.displayContainer(displayID), node.nextSibling);
                    }
                    break;
                case Skills.Fishing:
                    node = document.getElementById('fishing-area-menu-container').children[1 + index].children[0].children[0].children[3].children[0].children[1].children[1];
                    display = node.appendChild(ETA.displayContainer(displayID));
                    break;
                case Skills.Firemaking:
                    node = document.getElementById('skill-fm-logs-selected-qty');
                    node = node.parentNode.parentNode.parentNode;
                    display = node.parentNode.insertBefore(ETA.displayContainer(displayID), node.nextSibling);
                    break;
                case Skills.Cooking:
                    node = document.getElementById(`cooking-menu-container`).children[index].firstChild.firstChild.firstChild.firstChild.children[4];
                    ETA.displays[wrapperID] = false;
                    wrapper = document.createElement('div');
                    wrapper.className = 'col-12';
                    wrapper.id = wrapperID;
                    wrapper.appendChild(ETA.displayContainer(displayID));
                    display = node.parentNode.appendChild(wrapper);
                    break;
                case Skills.Mining:
                    node = document.getElementById(`mining-ores-container`).children[(11 + index + 1) % 11].childNodes[1].childNodes[1].childNodes[1].childNodes[8];
                    display = node.parentNode.insertBefore(ETA.displayContainer(displayID), node);
                    break;
                case Skills.Thieving:
                    document.getElementById(`mastery-screen-skill-10-${index}`)
                        .parentElement
                        .parentElement
                        .parentElement
                        .parentElement
                        .parentElement
                        .parentElement
                        .children[0]
                        .appendChild(ETA.displayContainer(displayID));
                    break;
                case Skills.Agility:
                    if (index === undefined) {
                        document.getElementById('agility-breakdown-items').appendChild(ETA.displayContainer(displayID));
                    } else {
                        node = document.getElementById(`skill-content-container-20`).children[index].children[0].children[0].children[1].children[0];
                        display = node.insertBefore(ETA.displayContainer(displayID), node.children[4]);
                    }
                    break;
                case Skills.Astrology:
                    node = document.getElementById(`astrology-container-content`).children[index].children[0].children[0].children[5];
                    ETA.displays[wrapperID] = false;
                    wrapper = document.createElement('div');
                    wrapper.className = 'col-12';
                    wrapper.id = wrapperID;
                    node.parentNode.insertBefore(wrapper, node);
                    display = wrapper.appendChild(ETA.displayContainer(displayID));
                    break;
                case Skills.Magic:
                    node = document.getElementById('magic-screen-cast').children[0].children[1];
                    display = node.appendChild(ETA.displayContainer('timeLeftMagic'));
                    break;
            }
            return display ? display.firstChild : undefined;
        }

        ETA.createAllDisplays = function () {
            Woodcutting.trees.forEach((_, i) => {
                ETA.createDisplay(Skills.Woodcutting, i);
            });
            ETA.createDisplay(Skills.Woodcutting);
            Fishing.areas.forEach((_, i) => {
                ETA.createDisplay(Skills.Fishing, i);
            });
            ETA.createDisplay(Skills.Firemaking);
            for (let i = 0; i < 3; i++) {
                ETA.createDisplay(Skills.Cooking, i);
            }
            Mining.rockData.forEach((_, i) => {
                ETA.createDisplay(Skills.Mining, i);
            });
            ETA.createDisplay(Skills.Smithing);
            Thieving.npcs.forEach(npc => {
                ETA.createDisplay(Skills.Thieving, npc.id);
            });
            ETA.createDisplay(Skills.Fletching);
            ETA.createDisplay(Skills.Crafting);
            ETA.createDisplay(Skills.Runecrafting);
            ETA.createDisplay(Skills.Herblore);
            game.agility.builtObstacles.forEach(obstacle => {
                ETA.createDisplay(Skills.Agility, obstacle.category);
            });
            ETA.createDisplay(Skills.Agility);
            ETA.createDisplay(Skills.Summoning);
            Astrology.constellations.forEach((_, i) => {
                ETA.createDisplay(Skills.Astrology, i);
            });
            ETA.createDisplay(Skills.Magic);
        }

        ETA.removeAllDisplays = () => {
            for (const displayID in ETA.displays) {
                if (ETA.displays[displayID]) {
                    document.getElementById(displayID).parentNode.remove();
                } else {
                    document.getElementById(displayID).remove();
                }
            }
            ETA.displays = {};
        }

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

        ETA.timeRemainingWrapper = function (skillID, checkTaskComplete) {
            // check if valid state
            switch (skillID) {
                case Skills.Firemaking:
                    if (game.firemaking.selectedRecipeID === -1) {
                        return;
                    }
                    break;
                case Skills.Smithing:
                    if (game.smithing.selectedRecipeID === -1) {
                        return;
                    }
                    break;
                case Skills.Fletching:
                    if (game.fletching.selectedRecipeID === -1) {
                        return;
                    }
                    break;
                case Skills.Crafting:
                    if (game.crafting.selectedRecipeID === -1) {
                        return;
                    }
                    break;
                case Skills.Runecrafting:
                    if (game.runecrafting.selectedRecipeID === -1) {
                        return;
                    }
                    break;
                case Skills.Magic:
                    if (game.altMagic.selectedSpellID === -1) {
                        return;
                    }
                    break;
                case Skills.Herblore:
                    if (game.herblore.selectedRecipeID === -1) {
                        return;
                    }
                    break;
                case Skills.Summoning:
                    if (game.summoning.selectedRecipeID === -1) {
                        return;
                    }
                    break;
            }
            // populate the main `time remaining` variables
            if (isGathering(skillID)) {
                gatheringWrapper(skillID, checkTaskComplete);
            } else {
                productionWrapper(skillID, checkTaskComplete);
            }
        }

        function gatheringWrapper(skillID, checkTaskComplete) {
            let data = [];
            // gathering skills
            switch (skillID) {
                case Skills.Mining:
                    data = Mining.rockData;
                    break;

                case Skills.Thieving:
                    data = Thieving.npcs;
                    break;

                case Skills.Woodcutting:
                    data = Woodcutting.trees;
                    break;

                case Skills.Fishing:
                    data = Fishing.areas;
                    break;

                case Skills.Agility:
                    data = [];
                    // only keep active chosen obstacles
                    for (let category = 0; category < 10; category++) {
                        const obstacle = game.agility.builtObstacles.get(category);
                        if (obstacle !== undefined) {
                            data.push(obstacle.id);
                        } else {
                            break;
                        }
                    }
                    break;
                case Skills.Astrology:
                    data = Astrology.constellations;
                    break;
            }
            if (data.length > 0) {
                if (skillID !== Skills.Agility) {
                    data.forEach((x, i) => {
                        if (skillID === Skills.Woodcutting
                            && game.woodcutting.activeTrees.size === 2
                            && game.woodcutting.activeTrees.has(Woodcutting.trees[i])) {
                            return;
                        }
                        let initial = initialVariables(skillID, checkTaskComplete);
                        if (initial.skillID === Skills.Fishing) {
                            initial.fish = game.fishing.selectedAreaFish.get(Fishing.areas[i]);
                            if (initial.fish === undefined) {
                                return;
                            }
                            initial.areaID = i;
                        }
                        initial.currentAction = i;
                        if (initial.skillID === Skills.Agility) {
                            initial.currentAction = x;
                            initial.agilityObstacles = data;
                        }
                        asyncTimeRemaining(initial);
                    });
                }
                if (skillID === Skills.Woodcutting) {
                    if (game.woodcutting.activeTrees.size === 2) {
                        // init first tree
                        let initial = initialVariables(skillID, checkTaskComplete);
                        initial.currentAction = [];
                        game.woodcutting.activeTrees.forEach(x => initial.currentAction.push(x.id));
                        initial.multiple = ETA.PARALLEL;
                        // run time remaining
                        asyncTimeRemaining(initial);
                    } else {
                        // wipe the display, there's no way of knowing which tree is being cut
                        const node = document.getElementById(`timeLeft${Skills[skillID]}`);
                        if (node) {
                            node.textContent = '';
                        }
                    }
                }
                if (skillID === Skills.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);
            if (skillID === Skills.Cooking) {
                game.cooking.selectedRecipes.forEach((recipe, i) => {
                    if (recipe === undefined) {
                        return;
                    }
                    let initial = initialVariables(skillID, checkTaskComplete);
                    initial.recipe = recipe;
                    initial.currentAction = recipe.masteryID;
                    initial.cookingCategory = i;
                    asyncTimeRemaining(initial);
                });
            }
            switch (initial.skillID) {
                case Skills.Smithing:
                    initial.currentAction = game.smithing.selectedRecipeID;
                    break;
                case Skills.Fletching:
                    initial.currentAction = game.fletching.selectedRecipeID;
                    break;
                case Skills.Runecrafting:
                    initial.currentAction = game.runecrafting.selectedRecipeID;
                    break;
                case Skills.Crafting:
                    initial.currentAction = game.crafting.selectedRecipeID;
                    break;
                case Skills.Herblore:
                    initial.currentAction = game.herblore.selectedRecipeID;
                    break;
                case Skills.Firemaking:
                    initial.currentAction = game.firemaking.selectedRecipeID;
                    break;
                case Skills.Magic:
                    initial.currentAction = game.altMagic.selectedSpellID;
                    break;
                case Skills.Summoning:
                    initial.currentAction = game.summoning.selectedRecipeID;
            }
            if (initial.currentAction === undefined) {
                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 player.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.skillID === Skills.Magic) {
                return initial.runePreservationChance;
            }
            if (!initial.hasMastery) {
                return 0;
            }
            const masteryLevel = convertXpToLvl(masteryXp);
            const itemID = initial.actions[0].itemID;
            // modifiers and base rhaelyx
            let preservationChance = initial.staticPreservation;
            // skill specific bonuses
            switch (initial.skillID) {
                case Skills.Cooking:
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 10;
                    }
                    break;
                case Skills.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;
                    }
                    if (initial.recipe.category === 7) {
                        preservationChance += player.modifiers.summoningSynergy_5_17;
                    }
                    break;
                case Skills.Fletching:
                    preservationChance += 0.2 * masteryLevel - 0.2;
                    if (masteryLevel >= 99) {
                        preservationChance += 5;
                    }
                    break;
                case Skills.Crafting:
                    preservationChance += 0.2 * masteryLevel - 0.2;
                    if (masteryLevel >= 99) {
                        preservationChance += 5;
                    }
                    if (poolReached(initial, poolXp, 1)) {
                        preservationChance += 5;
                    }
                    if (initial.recipe.category === CraftingCategory.Necklaces || initial.recipe.category === CraftingCategory.Rings) {
                        preservationChance += player.modifiers.summoningSynergy_16_17;
                    }
                    break;
                case Skills.Runecrafting:
                    if (game.runecrafting.isMakingRunes) {
                        preservationChance += player.modifiers.increasedRunecraftingEssencePreservation;
                    }
                    if (game.runecrafting.isMakingStaff) {
                        preservationChance += player.modifiers.summoningSynergy_3_10;
                    }
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 10;
                    }
                    break;
                case Skills.Herblore:
                    preservationChance += 0.2 * masteryLevel - 0.2;
                    if (masteryLevel >= 99) preservationChance += 5;
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 5;
                    }
                    break;
                case Skills.Summoning:
                    if (poolReached(initial, poolXp, 2)) {
                        preservationChance += 10;
                    }
                    break;
            }
            // rhaelyx is handled outside of this function

            // cap preservation to ub 80%
            if (preservationChance > 80) {
                preservationChance = 80;
            }
            // don't cap preservation to lb 0% at this point, still need to add charge stones
            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 Skills.Woodcutting:
                    if (convertXpToLvl(masteryXp) >= 99) {
                        flatReduction += 200;
                    }
                    break;
                case Skills.Firemaking:
                    if (poolReached(initial, poolXp, 1)) {
                        percentReduction += 10;
                    }
                    percentReduction += convertXpToLvl(masteryXp) * 0.1;
                    break;
                case Skills.Mining:
                    if (poolReached(initial, poolXp, 2)) {
                        flatReduction += 200;
                    }
                    break;
                case Skills.Crafting:
                    if (poolReached(initial, poolXp, 2)) {
                        flatReduction += 200;
                    }
                    break;
                case Skills.Fletching:
                    if (poolReached(initial, poolXp, 3)) {
                        flatReduction += 200;
                    }
                    break;
                case Skills.Agility:
                    percentReduction += 3 * Math.floor(convertXpToLvl(masteryXp) / 10);
                    break;
                case Skills.Thieving:
                    if (initial.currentAction === ThievingNPCs.FISHERMAN) {
                        percentReduction -= player.modifiers.summoningSynergy_5_11;
                    }
                    if (convertXpToLvl(masteryXp) >= 50) {
                        flatReduction += 200;
                    }
                    if (poolReached(initial, poolXp, 1)) {
                        flatReduction += 200;
                    }
                    break;
                case Skills.Smithing:
                    flatReduction += player.modifiers.summoningSynergy_9_17;
                    break;
                case Skills.Cooking:
                    flatReduction += player.modifiers.summoningSynergy_9_17;
                    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, skillXp, poolXp, masteryXp, agiLapTime) {
            let adjustedInterval = currentInterval;
            switch (initial.skillID) {
                case Skills.Mining:
                    // compute max rock HP
                    let rockHP = 5 /*base*/ + convertXpToLvl(masteryXp);
                    if (poolReached(initial, poolXp, 3)) {
                        rockHP += 10;
                    }
                    rockHP += player.modifiers.increasedMiningNodeHP - player.modifiers.decreasedMiningNodeHP;
                    // synergy 4 18
                    rockHP += player.modifiers.summoningSynergy_4_18;
                    // potions can preserve rock HP
                    let noDamageChance = player.modifiers.increasedChanceNoDamageMining - player.modifiers.decreasedChanceNoDamageMining;
                    if (noDamageChance >= 100) {
                        break;
                    }
                    rockHP /= (1 - noDamageChance / 100);
                    // compute average time per action
                    let spawnTime = Mining.rockData[initial.currentAction].baseRespawnInterval;
                    if (poolReached(initial, poolXp, 1)) {
                        spawnTime *= 0.9;
                    }
                    adjustedInterval = (adjustedInterval * rockHP + spawnTime) / rockHP;
                    break;

                case Skills.Thieving:
                    const successRate = getThievingSuccessRate(initial, currentInterval, skillXp, poolXp, masteryXp);
                    // stunTime = 3s + time of the failed action, since failure gives no xp or mxp
                    let stunTime = game.thieving.baseStunInterval + adjustedInterval;
                    // compute average time per action
                    adjustedInterval = adjustedInterval + stunTime / successRate - stunTime;
                    break;

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

        function getStealthAgainstNPC(initial, npc, skillXp, poolXp, masteryXp) {
            const mastery = convertXpToLvl(masteryXp);
            const level = convertXpToLvl(skillXp)
            let stealth = level + mastery;
            if (mastery >= 99) {
                stealth += 75;
            }
            if (poolReached(initial, poolXp, 0)) {
                stealth += 30;
            }
            if (poolReached(initial, poolXp, 3)) {
                stealth += 100;
            }
            stealth += player.modifiers.increasedThievingStealth;
            stealth -= player.modifiers.decreasedThievingStealth;
            return stealth;
        }

        function getThievingSuccessRate(initial, currentInterval, skillXp, poolXp, masteryXp) {
            const npc = Thieving.npcs[initial.currentAction];
            const stealth = getStealthAgainstNPC(initial, npc, skillXp, poolXp, masteryXp);
            return Math.min(100, (100 * (100 + stealth)) / (100 + npc.perception)) / 100;
        }

        // Adjust skill Xp based on unlocked bonuses
        function skillXpAdjustment(initial, itemXp, itemID, poolXp, masteryXp) {
            let staticXpBonus = initial.staticXpBonus;
            switch (initial.skillID) {
                case Skills.Herblore:
                    if (poolReached(initial, poolXp, 1)) {
                        staticXpBonus += 0.03;
                    }
                    break;
                case Skills.Thieving:
                    if (poolReached(initial, poolXp, 0)) {
                        staticXpBonus += 0.03;
                    }
                    break;
            }
            let xpMultiplier = 1;
            switch (initial.skillID) {
                case Skills.Runecrafting:
                    if (poolReached(initial, poolXp, 1) && game.runecrafting.isMakingRunes) {
                        xpMultiplier += 1.5;
                    }
                    break;

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

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

                case Skills.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[Skills[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 (player.equipment.slots.Amulet.item.id === Items.Clue_Chasers_Insignia) {
                actions *= ETA.insigniaModifier;
            }
            return actions;
        }

        function isGathering(skillID) {
            return [
                Skills.Woodcutting,
                Skills.Fishing,
                Skills.Mining,
                Skills.Thieving,
                Skills.Agility,
                Skills.Astrology,
            ].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 !== Skills.Magic, // magic has no mastery, so we often check this
                multiple: ETA.SINGLE,
                completionCape: player.equipment.slots.Cape.item.id === Items.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
                maxPoolXp: 0,
                tokens: 0,
                poolLimCheckpoints: [10, 25, 50, 95, 100, Infinity], //Breakpoints for mastery pool bonuses followed by Infinity
                // preservation
                staticPreservation: 0,
                runePreservationChance: game.altMagic.runePreservationChance,
                //////////////
                //DEPRECATED//
                //////////////
                masteryID: undefined,
                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 = player.modifiers.increasedGlobalPreservationChance;
            initial.staticPreservation -= player.modifiers.decreasedGlobalPreservationChance;
            initial.staticPreservation += getTotalFromModifierArray("increasedSkillPreservationChance", skillID);
            initial.staticPreservation -= getTotalFromModifierArray("decreasedSkillPreservationChance", skillID);
            if (player.equipment.slots.Helmet.item.id === Items.Crown_of_Rhaelyx
                && getBankQty(Items.Charge_Stone_of_Rhaelyx) > 0) {
                initial.staticPreservation -= ETA.rhaelyxChargePreservation; // Remove stone 15% chance from base
            }
            return initial;
        }

        function skillCapeEquipped(capeID) {
            return [
                capeID,
                Items.Max_Skillcape,
                Items.Cape_of_Completion,
            ].includes(player.equipment.slots.Cape.item.id);
        }

        function configureSmithing(initial) {
            initial.recipe = Smithing.recipes[initial.currentAction];
            initial.masteryID = initial.recipe.masteryID;
            initial.itemXp = initial.recipe.baseXP;
            initial.skillInterval = game.smithing.baseInterval;
            for (let i of initial.recipe.itemCosts) {
                const req = {...i};
                if (req.id === Items.Coal_Ore) {
                    if (skillCapeEquipped(Items.Smithing_Skillcape)) {
                        req.qty /= 2;
                    }
                    req.qty -= player.modifiers.summoningSynergy_17_19;
                    if (req.qty < 0) {
                        req.qty = 0;
                    }
                }
                initial.skillReq.push(req);
            }
            initial.masteryLimLevel = [20, 40, 60, 80, 99, Infinity]; // Smithing Mastery Limits
            return initial;
        }

        function configureFletching(initial) {
            initial.recipe = Fletching.recipes[initial.currentAction];
            initial.itemID = initial.recipe.itemID;
            initial.itemXp = initial.recipe.baseXP;
            initial.skillInterval = game.fletching.baseInterval;
            let costs = initial.recipe.itemCosts;
            if (initial.recipe.alternativeCosts !== undefined) {
                costs = initial.recipe.alternativeCosts[game.fletching.selectedAltRecipe].itemCosts;
            }
            for (let i of costs) {
                initial.skillReq.push(i);
            }
            return initial;
        }

        function configureRunecrafting(initial) {
            initial.recipe = Runecrafting.recipes[initial.currentAction];
            initial.itemID = initial.recipe.itemID;
            initial.itemXp = initial.recipe.baseXP;
            initial.skillInterval = game.runecrafting.baseInterval;
            for (let i of initial.recipe.itemCosts) {
                initial.skillReq.push(i);
            }
            initial.masteryLimLevel = [99, Infinity]; // Runecrafting has no Mastery bonus
            return initial;
        }

        function configureCrafting(initial) {
            initial.recipe = Crafting.recipes[initial.currentAction];
            initial.itemID = initial.recipe.itemID;
            initial.itemXp = initial.recipe.baseXP;
            initial.skillInterval = game.crafting.baseInterval;
            for (let i of initial.recipe.itemCosts) {
                let qty = i.qty;
                if (initial.recipe.category === CraftingCategory.Dragonhide) {
                    qty -= player.modifiers.summoningSynergy_9_16;
                }
                initial.skillReq.push({
                    ...i,
                    qty: Math.max(1, qty),
                });
            }

            return initial;
        }

        function configureHerblore(initial) {
            initial.recipe = Herblore.potions[initial.currentAction];
            initial.itemXp = initial.recipe.baseXP;
            initial.masteryID = initial.recipe.masteryID;
            initial.skillInterval = game.herblore.baseInterval;
            for (let i of initial.recipe.itemCosts) {
                initial.skillReq.push(i);
            }
            return initial;
        }

        function configureCooking(initial) {
            initial.itemID = initial.recipe.id;
            initial.masteryID = initial.recipe.masteryID;
            initial.itemXp = initial.recipe.baseXP;
            initial.skillInterval = initial.recipe.baseInterval;
            initial.skillReq = initial.recipe.itemCosts;
            initial.masteryLimLevel = [99, Infinity]; //Cooking has no Mastery bonus
            return initial;
        }

        function configureFiremaking(initial) {
            initial.recipe = Firemaking.recipes[initial.currentAction];
            initial.itemXp = initial.recipe.baseXP * (1 + initial.recipe.bonfireXPBonus / 100);
            initial.masteryID = initial.recipe.masteryID;
            initial.skillInterval = initial.recipe.baseInterval;
            initial.skillReq = [{id: initial.recipe.logID, qty: 1}];
            return initial;
        }

        function configureSummoning(initial) {
            initial.recipe = Summoning.marks[initial.currentAction];
            initial.altRecipeID = game.summoning.setAltRecipes.get(initial.recipe);
            initial.itemID = initial.recipe.itemID;
            initial.itemXp = initial.recipe.baseXP;
            initial.useTabletXp = Summoning.getTabletConsumptionXP(initial.currentAction, true);
            initial.skillInterval = game.summoning.baseInterval;
            // costs can change with increasing pool / mastery
            initial.skillReq = calcSummoningRecipeQty(initial, 0, 1);
            // 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 = game.summoning.actionDoublingChance;
            return initial;
        }

        function configureMagic(initial) {
            initial.skillInterval = game.altMagic.baseInterval;
            initial.recipe = AltMagic.spells[initial.currentAction];
            initial.selectedConversionItem = game.altMagic.selectedConversionItem;
            initial.selectedSmithingRecipe = game.altMagic.selectedSmithingRecipe;
            //Find need runes for spell
            game.altMagic.getCurrentRecipeRuneCosts()._items.forEach((qty, itemID) => {
                if (itemID > -1) {
                    initial.skillReq.push({id: itemID, qty: qty});
                }
            });
            // Get Rune discount
            let capeMultiplier = 1;
            if (skillCapeEquipped(Items.Magic_Skillcape)) {
                // Add cape multiplier
                capeMultiplier = 2;
            }
            for (let i = 0; i < initial.skillReq.length; i++) {
                const weapon = player.equipment.slots.Weapon.item;
                if (weapon.providesRune !== undefined && weapon.providesRune.includes(initial.skillReq[i].id)) {
                    initial.skillReq[i].qty -= weapon.providesRuneQty * capeMultiplier;
                }
            }
            initial.skillReq = initial.skillReq.filter(item => item.qty > 0); // Remove all runes with 0 cost
            //Other items
            game.altMagic.getCurrentRecipeCosts()._items.forEach((qty, itemID) => {
                if (itemID > -1) {
                    initial.skillReq.push({id: itemID, qty: qty});
                }
            });
            //
            initial.masteryLimLevel = [Infinity]; //AltMagic has no Mastery bonus
            initial.itemXp = initial.recipe.baseExperience;
            return initial;
        }

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

        function configureMining(initial) {
            initial.itemID = Mining.rockData[initial.currentAction].oreID;
            initial.itemXp = Mining.rockData[initial.currentAction].baseExperience;
            initial.skillInterval = game.mining.baseInterval;
            return configureGathering(initial);
        }

        function configureThieving(initial) {
            initial.itemID = undefined;
            initial.itemXp = Thieving.npcs[initial.currentAction].xp;
            initial.skillInterval = game.thieving.baseInterval;
            return configureGathering(initial);
        }

        function configureWoodcutting(initial) {
            const wcAction = x => {
                return {
                    itemID: Woodcutting.trees[x].logID,
                    itemXp: Woodcutting.trees[x].baseExperience,
                    skillInterval: Woodcutting.trees[x].baseInterval,
                    masteryID: Woodcutting.trees[x].id,
                };
            }
            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 = initial.fish.itemID;
            initial.itemXp = initial.fish.baseXP;
            // base avg interval
            let avgRoll = 0.5;
            const max = initial.fish.baseMaxInterval;
            const min = initial.fish.baseMinInterval;
            initial.skillInterval = Math.floor(avgRoll * (max - min)) + min;
            initial.currentAction = initial.fish.masteryID;
            initial = configureGathering(initial);
            return initial
        }

        function configureAgility(initial) {
            const agiAction = x => {
                return {
                    itemXp: Agility.obstacles[x].completionBonuses.xp,
                    skillInterval: Agility.obstacles[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 configureAstrology(initial) {
            initial.itemID = undefined;
            initial.itemXp = Astrology.constellations[initial.currentAction].provides.xp;
            initial.skillInterval = Astrology.baseInterval;
            return configureGathering(initial);
        }

        function calcShardReduction(initial, poolXp, masteryLevel) {
            let shardReduction = 0;
            // mastery shard reduction
            if (masteryLevel >= 50) {
                shardReduction++;
            }
            if (masteryLevel >= 99) {
                shardReduction++;
            }
            // pool shard reduction
            if (poolReached(initial, poolXp, 1) && initial.recipe.tier <= 2) {
                shardReduction++;
            } else if (poolReached(initial, poolXp, 3) && initial.recipe.tier === 3) {
                shardReduction++;
            }
            // modifier shard reduction
            shardReduction += player.modifiers.decreasedSummoningShardCost - player.modifiers.increasedSummoningShardCost;
            return shardReduction;
        }

        function calcSummoningRecipeQtyMap(initial, poolXp, masteryLevel) {
            const map = {};
            calcSummoningRecipeQty(initial, poolXp, masteryLevel).forEach(x => map[x.id] = x.qty);
            return map;
        }

        function calcSummoningRecipeQty(initial, poolXp, masteryLevel) {
            // shard costs
            const shardReduction = calcShardReduction(initial, poolXp, masteryLevel);
            const recipe = initial.recipe.itemCosts.map(x => {
                return {
                    id: x.id,
                    qty: Math.max(1, x.qty - shardReduction),
                }
            });

            // cost multiplier
            let nonShardCostReduction = 0;
            // Non-Shard Cost reduction that scales with mastery level
            nonShardCostReduction += Math.floor(masteryLevel / 10) * 5;
            // Level 99 Mastery: +5% Non Shard Cost Reduction
            if (masteryLevel >= 99) {
                nonShardCostReduction += 5;
            }
            const costMultiplier = 1 - nonShardCostReduction / 100;

            // currency cost
            if (initial.recipe.gpCost > 0) {
                recipe.push({
                    id: -4,
                    qty: Math.max(initial.recipe.gpCost * costMultiplier),
                });
            }
            if (initial.recipe.scCost > 0) {
                recipe.push({
                    id: -5,
                    qty: Math.max(initial.recipe.scCost * costMultiplier),
                });
            }

            // non-shard item cost
            if (initial.recipe.nonShardItemCosts.length > 0) {
                const itemID = initial.recipe.nonShardItemCosts[initial.altRecipeID ?? 0];
                const itemCost = Math.max(20, items[itemID].sellsFor);
                recipe.push({
                    id: itemID,
                    qty: Math.max(1, Math.floor(Summoning.recipeGPCost * costMultiplier / itemCost)),
                });
            }

            // return all costs
            return recipe;
        }

        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, masteryID) {
            const modifiedTimePerAction = getTimePerActionModifierMastery(initial.skillID, timePerAction, masteryID);
            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 === Skills.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[Skills.Firemaking].xp.length; i++) {
                    // The logs you are not burning
                    if (initial.actions[0].masteryID !== i) {
                        if (getMasteryLevel(Skills.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(Skills.Firemaking) >= masteryCheckpoints[3]) {
                    xpModifier += 0.05;
                }
                for (let i = 0; i < MASTERY[Skills.Firemaking].xp.length; i++) {
                    if (getMasteryLevel(Skills.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 === Skills.Cooking) {
                let burnChance = calcBurnChance(masteryXp);
                xpToAdd *= (1 - burnChance);
            }
            // Fishing junk gives no mastery xp
            if (initial.skillID === Skills.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) {
            // primary burn chance
            let primaryBurnChance = 30;
            primaryBurnChance += player.modifiers.summoningSynergy_4_9;
            primaryBurnChance -= player.modifiers.increasedChanceSuccessfulCook;
            primaryBurnChance += player.modifiers.decreasedChanceSuccessfulCook;
            primaryBurnChance -= (convertXpToLvl(masteryXp) - 1) * 0.6;
            if (primaryBurnChance < 0) {
                primaryBurnChance = 0;
            }
            // total burn chance
            return primaryBurnChance / 100;
        }

        // calculate junk chance
        function calcJunkChance(initial, masteryXp, poolXp) {
            // base
            let junkChance = Fishing.areas[initial.areaID].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 (player.equipment.slots.Helmet.item.id === Items.Crown_of_Rhaelyx && initial.hasMastery && !initial.isGathering) {
                let rhaelyxCharge = getQtyOfItem(Items.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].masteryID);
                    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 === Skills.Agility) {
                current.agiLapTime = currentIntervals.reduce((a, b) => a + b, 0);
            }
            const averageActionTimes = current.actions.map((x, i) => intervalRespawnAdjustment(initial, currentIntervals[i], current.skillXp, current.poolXp, x.masteryXp, current.agiLapTime));
            // Current Xp
            let gains = gainPerAction(initial, current, currentIntervals);
            current.gains = gains;

            // 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 / 100));
            // update summoning costs
            if (initial.skillID === Skills.Summoning) {
                const masteryLevel = convertXpToLvl(current.actions[0].masteryXp);
                current.skillReqMap = calcSummoningRecipeQtyMap(initial, current.poolXp, masteryLevel);
            }
            // 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 !== Skills.Agility) {
                return;
            }
            // check if we need to halve one of the debuffs
            const m = Agility.obstacles[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] === Skills.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.skillXp, 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.masteryID);
                    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
            let sumTotalTime = current.sumTotalTime;
            while (!initial.isGathering && resourcesLeft(current.itemQty, current.skillReqMap)) {
                current = actionsToBreakpoint(initial, current, false);
                if (sumTotalTime === current.sumTotalTime || isNaN(current.sumTotalTime) || !isFinite(current.sumTotalTime)) {
                    ETA.log(sumTotalTime)
                    ETA.log(JSON.parse(JSON.stringify(initial)));
                    ETA.log(JSON.parse(JSON.stringify(current)));
                    break;
                }
                sumTotalTime = current.sumTotalTime;
            }

            // 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 (sumTotalTime === current.sumTotalTime || isNaN(current.sumTotalTime) || !isFinite(current.sumTotalTime)) {
                    ETA.log(JSON.parse(JSON.stringify(initial)));
                    ETA.log(JSON.parse(JSON.stringify(current)));
                    break;
                }
                sumTotalTime = current.sumTotalTime;
            }
            // 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 Skills.Smithing:
                    initial = configureSmithing(initial);
                    break;
                case Skills.Fletching:
                    initial = configureFletching(initial);
                    break;
                case Skills.Runecrafting:
                    initial = configureRunecrafting(initial);
                    break;
                case Skills.Crafting:
                    initial = configureCrafting(initial);
                    break;
                case Skills.Herblore:
                    initial = configureHerblore(initial);
                    break;
                case Skills.Cooking:
                    initial = configureCooking(initial);
                    break;
                case Skills.Firemaking:
                    initial = configureFiremaking(initial);
                    break;
                case Skills.Magic:
                    initial = configureMagic(initial);
                    break;
                case Skills.Mining:
                    initial = configureMining(initial);
                    break;
                case Skills.Thieving:
                    initial = configureThieving(initial);
                    break;
                case Skills.Woodcutting:
                    initial = configureWoodcutting(initial);
                    break;
                case Skills.Fishing:
                    initial = configureFishing(initial);
                    break;
                case Skills.Agility:
                    initial = configureAgility(initial);
                    break;
                case Skills.Summoning:
                    initial = configureSummoning(initial);
                    break;
                case Skills.Astrology:
                    initial = configureAstrology(initial);
                    break;
            }
            // 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 === Skills.Agility) {
                // set initial lap time
                initial.agiLapTime = 0;
                if (initial.skillID === Skills.Agility) {
                    const poolXp = MASTERY[initial.skillID].pool;
                    initial.agilityObstacles.forEach(x => {
                        const masteryXp = MASTERY[initial.skillID].xp[x];
                        const interval = Agility.obstacles[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(Items["Mastery_Token_" + Skills[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 = initial.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 += (player.modifiers.increasedGlobalSkillXP - player.modifiers.decreasedGlobalSkillXP) / 100;
            if (skill === Skills.Magic) {
                xpMultiplier += (player.modifiers.increasedAltMagicSkillXP - player.modifiers.decreasedAltMagicSkillXP) / 100;
            }
            // TODO: does not match the test-v0.21?980 implementation
            if (skill === Skills.Firemaking
                && player.modifiers.summoningSynergy_18_19
                && herbloreBonuses[8].bonus[0] === 0
                && herbloreBonuses[8].bonus[1] > 0) {
                xpMultiplier += 5 / 100;
            }
            return xpMultiplier;
        }

        function getStaticMXPBonuses(skill) {
            let xpMultiplier = 1;
            xpMultiplier += getTotalFromModifierArray("increasedMasteryXP", skill) / 100;
            xpMultiplier -= getTotalFromModifierArray("decreasedMasteryXP", skill) / 100;
            xpMultiplier += (player.modifiers.increasedGlobalMasteryXP - player.modifiers.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) {
            let index = undefined;
            if (initial.actions.length === 1) {
                if (initial.skillID === Skills.Fishing) {
                    index = initial.areaID;
                } else if (initial.skillID === Skills.Agility) {
                    index = Agility.obstacles[initial.currentAction].category;
                } else if (initial.isGathering) {
                    index = initial.currentAction;
                } else if (initial.cookingCategory !== undefined) {
                    index = initial.cookingCategory;
                }
            }
            const timeLeftElement = ETA.createDisplay(initial.skillID, index);
            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 === Skills.Cooking)) {
                const itemID = initial.actions[0].itemID;
                if (itemID !== undefined) {
                    const youHaveElementId = timeLeftElement.id + "-YouHave";
                    const perfectID = items[itemID].perfectItem;
                    const youHaveElement = document.getElementById(youHaveElementId);
                    while (youHaveElement.lastChild) {
                        youHaveElement.removeChild(youHaveElement.lastChild);
                    }
                    const span = document.createElement('span');
                    span.textContent = `You have: ${formatNumber(getQtyOfItem(itemID))}`;
                    youHaveElement.appendChild(span);
                    const img = document.createElement('img');
                    img.classList = 'skill-icon-xs mr-2';
                    img.src = items[itemID].media;
                    youHaveElement.appendChild(img);
                    if (perfectID !== undefined) {
                        const perfectSpan = document.createElement('span');
                        perfectSpan.textContent = `You have: ${formatNumber(getQtyOfItem(perfectID))}`;
                        youHaveElement.appendChild(perfectSpan);
                        const perfectImg = document.createElement('img');
                        perfectImg.classList = 'skill-icon-xs mr-2';
                        perfectImg.src = items[perfectID].media;
                        youHaveElement.appendChild(perfectImg);
                    }
                }
            }
            timeLeftElement.style.display = "block";
            if (timeLeftElement.textContent.length === 0) {
                timeLeftElement.textContent = "Melvor ETA";
            }
            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[Items.Clue_Chasers_Insignia].increasedItemChance / 100;
        // rhaelyx goes from 10% to 25% with charge stones
        ETA.rhaelyxChargePreservation = conditionalModifiers.get(Items.Crown_of_Rhaelyx)[0].modifiers.increasedGlobalPreservationChance;

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

        ETA.updateSkillWindowRef = updateSkillWindow;
        updateSkillWindow = function (skill) {
            try {
                ETA.timeRemainingWrapper(skill, false);
            } catch (e) {
                ETA.error(e);
            }
            ETA.updateSkillWindowRef(skill);
        };

        // update tick-based skills
        ETA.startActionTimer = (skillName, propName) => {
            if (game.loopStarted) {
                // call ETA if game loop is active, in particular do not call ETA when catching up
                try {
                    ETA.timeRemainingWrapper(Skills[skillName], false);
                } catch (e) {
                    ETA.error(e);
                }
            }
            // mimic Craftingskill.startActionTimer
            game[propName].actionTimer.start(game[propName].actionInterval);
            game[propName].renderQueue.progressBar = true;
        }

        ETA.selectRecipeOnClick = (skillName, propName, recipeID) => {
            if (recipeID !== game[propName].selectedRecipeID && game[propName].isActive && !game[propName].stop())
                return;
            game[propName].selectedRecipeID = recipeID;
            game[propName].renderQueue.selectedRecipe = true;
            game[propName].render();
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        ETA.selectLog = (skillName, propName, recipeID) => {
            const recipeToSelect = Firemaking.recipes[recipeID];
            if (recipeToSelect.level > game[propName].level) {
                notifyPlayer(game[propName].id, getLangString('TOASTS', 'LEVEL_REQUIRED_TO_BURN'), 'danger');
            } else {
                if (game[propName].selectedRecipeID !== recipeID && game[propName].isActive && !game[propName].stop())
                    return;
                game[propName].selectedRecipeID = recipeID;
                game[propName].renderQueue.selectedLog = true;
                game[propName].renderQueue.logQty = true;
                try {
                    ETA.timeRemainingWrapper(Skills[skillName], false);
                } catch (e) {
                    ETA.error(e);
                }
            }
        }

        ETA.selectSpellOnClick = (skillName, propName, spellID) => {
            if (game[propName].selectedSpellID !== spellID) {
                if (game[propName].isActive && !game[propName].stop())
                    return;
                game[propName].selectedConversionItem = -1;
            }
            game[propName].selectedSpellID = spellID;
            game[propName].renderQueue.selectedSpellImage = true;
            game[propName].renderQueue.selectedSpellInfo = true;
            hideElement(altMagicItemMenu);
            showElement(altMagicMenu);
            game[propName].render();
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        ETA.selectItemOnClick = (skillName, propName, itemID) => {
            if (game.isGolbinRaid)
                return;
            game[propName].selectedConversionItem = itemID;
            game[propName].renderQueue.selectedSpellInfo = true;
            hideElement(altMagicItemMenu);
            showElement(altMagicMenu);
            game[propName].render();
            altMagicMenu.setSpellImage(game[propName]);
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        ETA.selectBarOnClick = (skillName, propName, recipe) => {
            if (game.isGolbinRaid)
                return;
            game[propName].selectedSmithingRecipe = recipe;
            game[propName].renderQueue.selectedSpellInfo = true;
            hideElement(altMagicItemMenu);
            showElement(altMagicMenu);
            game[propName].render();
            altMagicMenu.setSpellImage(game[propName]);
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        ETA.onRecipeSelectionClick = (skillName, propName, recipe) => {
            const category = recipe.category;
            const existingRecipe = game[propName].selectedRecipes.get(category);
            if (game[propName].isActive) {
                if (category === game[propName].activeCookingCategory && recipe !== game[propName].activeRecipe && !game[propName].stop())
                    return;
                else if (game[propName].passiveCookTimers.has(category) && recipe !== existingRecipe && !game[propName].stopPassiveCooking(category))
                    return;
            }
            game[propName].selectedRecipes.set(category, recipe);
            game[propName].renderQueue.selectedRecipes.add(category);
            game[propName].renderQueue.recipeRates = true;
            game[propName].renderQueue.quantities = true;
            game[propName].render();
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        ETA.selectAltRecipeOnClick = (skillName, propName, altID) => {
            if (altID !== game[propName].selectedAltRecipe && game[propName].isActive && !game[propName].stop())
                return;
            game[propName].setAltRecipes.set(game[propName].selectedRecipe, altID);
            game[propName].renderQueue.selectedRecipe = true;
            game[propName].render();
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        ETA.onAreaFishSelection = (skillName, propName, area, fish) => {
            const previousSelection = game[propName].selectedAreaFish.get(area);
            if (area === game[propName].activeFishingArea && previousSelection !== fish && game[propName].isActive && !game[propName].stop())
                return;
            game[propName].selectedAreaFish.set(area, fish);
            game[propName].renderQueue.selectedAreaFish = true;
            game[propName].renderQueue.selectedAreaFishRates = true;
            game[propName].renderQueue.areaChances = true;
            game[propName].renderQueue.actionMastery.add(fish.masteryID);
            game[propName].render();
            try {
                ETA.timeRemainingWrapper(Skills[skillName], false);
            } catch (e) {
                ETA.error(e);
            }
        }

        // gathering, only override startActionTimer
        game.woodcutting.startActionTimer = () => ETA.startActionTimer('Woodcutting', 'woodcutting');
        game.fishing.startActionTimer = () => ETA.startActionTimer('Fishing', 'fishing');
        game.fishing.onAreaFishSelection = (area, fish) => ETA.onAreaFishSelection('Fishing', 'fishing', area, fish);
        game.mining.startActionTimer = () => {
            if (!game.mining.selectedRockActiveData.isRespawning) {
                ETA.startActionTimer('Mining', 'mining');
            }
        }
        game.thieving.startActionTimer = () => {
            if (!game.thieving.isStunned) {
                ETA.startActionTimer('Thieving', 'thieving');
            }
        }
        game.agility.startActionTimer = () => ETA.startActionTimer('Agility', 'agility');
        game.astrology.startActionTimer = () => ETA.startActionTimer('Astrology', 'astrology');

        // production, override startActionTimer and selectXOnClick
        game.firemaking.startActionTimer = () => ETA.startActionTimer('Firemaking', 'firemaking');
        game.firemaking.selectLog = (recipeID) => ETA.selectLog('Firemaking', 'firemaking', recipeID);
        game.cooking.startActionTimer = () => ETA.startActionTimer('Cooking', 'cooking');
        game.cooking.onRecipeSelectionClick = (recipe) => ETA.onRecipeSelectionClick('Cooking', 'cooking', recipe);
        game.smithing.startActionTimer = () => ETA.startActionTimer('Smithing', 'smithing');
        game.smithing.selectRecipeOnClick = (recipeID) => ETA.selectRecipeOnClick('Smithing', 'smithing', recipeID);
        game.fletching.startActionTimer = () => ETA.startActionTimer('Fletching', 'fletching');
        game.fletching.selectRecipeOnClick = (recipeID) => ETA.selectRecipeOnClick('Fletching', 'fletching', recipeID);
        game.fletching.selectAltRecipeOnClick = (altID) => ETA.selectAltRecipeOnClick('Fletching', 'fletching', altID);
        game.crafting.startActionTimer = () => ETA.startActionTimer('Crafting', 'crafting');
        game.crafting.selectRecipeOnClick = (recipeID) => ETA.selectRecipeOnClick('Crafting', 'crafting', recipeID);
        game.runecrafting.startActionTimer = () => ETA.startActionTimer('Runecrafting', 'runecrafting');
        game.runecrafting.selectRecipeOnClick = (recipeID) => ETA.selectRecipeOnClick('Runecrafting', 'runecrafting', recipeID);
        game.herblore.startActionTimer = () => ETA.startActionTimer('Herblore', 'herblore');
        game.herblore.selectRecipeOnClick = (recipeID) => ETA.selectRecipeOnClick('Herblore', 'herblore', recipeID);
        game.summoning.startActionTimer = () => ETA.startActionTimer('Summoning', 'summoning');
        game.summoning.selectRecipeOnClick = (recipeID) => ETA.selectRecipeOnClick('Summoning', 'summoning', recipeID);
        game.summoning.selectAltRecipeOnClick = (altID) => ETA.selectAltRecipeOnClick('Summoning', 'summoning', altID);
        game.altMagic.startActionTimer = () => ETA.startActionTimer('Magic', 'altMagic');
        game.altMagic.selectSpellOnClick = (recipeID) => ETA.selectSpellOnClick('Magic', 'altMagic', recipeID);
        game.altMagic.selectItemOnClick = (recipeID) => ETA.selectItemOnClick('Magic', 'altMagic', recipeID);
        game.altMagic.selectBarOnClick = (recipeID) => ETA.selectBarOnClick('Magic', 'altMagic', recipeID);

        // Create timeLeft containers
        ETA.createAllDisplays();

        // 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 = Skills[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);

        // regularly save settings to local storage
        setInterval(window.ETASettings.save, 1000)
    }

    function loadScript() {
        if (typeof isLoaded !== typeof undefined) {
            startETASettings();
        }
        if (typeof isLoaded !== typeof undefined && isLoaded) {
            // Only load script after game has opened
            clearInterval(scriptLoader);
            startETA();
        }
    }

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