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.

اعتبارا من 20-11-2020. شاهد أحدث إصدار.

// ==UserScript==
// @name		Melvor ETA
// @namespace	http://tampermonkey.net/
// @version		0.1.16-0.17
// @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-Time-Remaining/issues or message TinyCoyote#1769 on Discord
// @description The last part of the version number is the most recent version of Melvor that was tested with this script. More recent versions might break the script.
// @description	Forked from Breindahl#2660's Melvor TimeRemaining script v0.6.2.2., originally developed by Breindahl#2660, Xhaf#6478 and Visua#9999
// @author		GMiclotte
// @match		https://melvoridle.com/*
// @match		https://www.melvoridle.com/*
// @match		https://test.melvoridle.com/*
// @grant		none
// ==/UserScript==
/* jshint esversion: 9 */

// script to inject
function script() {
	// Loading script
	console.log('Melvor ETA Loaded');

	// 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 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: false,
		// 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,
		/*
			targets
		 */
		// Default global target level / mastery / pool% is 99 / 99 / 100
		GLOBAL_TARGET_LEVEL: 99,
		GLOBAL_TARGET_MASTERY: 99,
		GLOBAL_TARGET_POOL: 100,
		// skill specific targets can be defined here, these override the global targets
		TARGET_LEVEL: {
			// [CONSTANTS.skill.Firemaking]: 120,
		},
		TARGET_MASTERY: {
			// [CONSTANTS.skill.Herblore]: 90,
		},
		TARGET_POOL: {
			// [CONSTANTS.skill.Crafting]: 25,
		},
		// returns the appropriate target
		getTarget: (global, specific, defaultTarget) => {
			let target = defaultTarget;
			if (Number.isInteger(global)) {
				target = global;
			}
			if (Number.isInteger(specific)) {
				target = specific;
			}
			if (target <= 0) {
				target = defaultTarget;
			}
 			return Math.ceil(target);
		},
		getTargetLevel: (skillID) => {
			return ETASettings.getTarget(ETASettings.GLOBAL_TARGET_LEVEL, ETASettings.TARGET_LEVEL[skillID], 99);
		},
		getTargetMastery: (skillID) => {
			return ETASettings.getTarget(ETASettings.GLOBAL_TARGET_MASTERY, ETASettings.TARGET_MASTERY[skillID], 99);
		},
		getTargetPool: (skillID) => {
			return ETASettings.getTarget(ETASettings.GLOBAL_TARGET_POOL, ETASettings.TARGET_POOL[skillID], 100);
		},

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

	// Function to check if task is complete
	function taskComplete(skillID) {
		if (window.timeLeftLast > 1 && window.timeLeftCurrent === 0) {
			notifyPlayer(skillID, "Task Done", "danger");
			console.log('Melvor ETA: task done');
			let ding = new Audio("https://www.myinstants.com/media/sounds/ding-sound-effect.mp3");
			ding.volume=0.1;
			ding.play();
		}
	}

	// Function to get unformatted number for Qty
	function getQtyOfItem(itemID) {
		for (let i = 0; i < bank.length; i++) {
			if (bank[i].id === itemID) {
				return bank[i].qty;
			}
		}
		return 0;
	}

	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 seconds to hours/minutes/seconds and format them
	function secondsToHms(time, isShortClock = ETASettings.IS_SHORT_CLOCK) {
		time = Number(time);
		// split seconds in days, hours, minutes and seconds
		let d = Math.floor(time / 86400)
		let h = Math.floor(time % 86400 / 3600);
		let m = Math.floor(time % 3600 / 60);
		let s = Math.floor(time % 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 AddSecondsToDate(date, seconds) {
		return new Date(date.getTime() + seconds * 1000);
	}

	// Days between now and then
	function daysBetween(now, then) {
		const startOfDayNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
		return Math.floor((then - startOfDayNow) / 1000 / 60 / 60 / 24 + (startOfDayNow.getTimezoneOffset() - then.getTimezoneOffset()) / (60 * 24));
	}

	// Format date 24 hour clock
	function DateFormat(now, then, is12h = ETASettings.IS_12H_CLOCK, isShortClock = ETASettings.IS_SHORT_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;
	}

	// Level to Xp Array
	const lvlToXp = Array.from({ length: 200 }, (_, i) => exp.level_to_xp(i));

	// 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 = lvlToXp[level] + 1;
		return xp;
	}

	// Convert Xp value to level
	function convertXpToLvl(xp, noCap = false) {
		let level = 1;
		while (lvlToXp[level] < xp) { level++; }
		level--;
		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 chanceToKeep for any mastery EXp
	function masteryPreservation(initial, masteryEXp, chanceToRefTable){
		let chanceTo = chanceToRefTable;
		if (masteryEXp >= initial.masteryLim[0]) {
			for (let i = 0; i < initial.masteryLim.length; i++) {
				if (initial.masteryLim[i] <= masteryEXp && masteryEXp < initial.masteryLim[i+1]) {
					return chanceTo[i+1];
				}
			}
		} else {return chanceTo[0];}
	}

	// Adjust interval based on unlocked bonuses
	function intervalAdjustment(initial, poolXp, masteryXp) {
		let adjustedInterval = initial.skillInterval;
		switch (initial.skillID) {
			case CONSTANTS.skill.Firemaking:
				if (poolXp >= initial.poolLim[1]) {
					adjustedInterval *= 0.9;
				}
				adjustedInterval *= 1 - convertXpToLvl(masteryXp) * 0.001;
				break;

			case CONSTANTS.skill.Crafting:
			case CONSTANTS.skill.Mining:
				// pool bonus speed
				if (poolXp >= initial.poolLim[2]) {
					adjustedInterval -= 200;
				}
				break;

			case CONSTANTS.skill.Fletching:
				if (poolXp >= initial.poolLim[3]) {
					adjustedInterval -= 200;
				}
				break;

			case CONSTANTS.skill.Woodcutting:
				if (convertXpToLvl(masteryXp) >= 99) {
					adjustedInterval -= 200;
				}
		}
		return adjustedInterval;
	}

	// Adjust interval based on unlocked bonuses
	function intervalRespawnAdjustment(initial, currentInterval, poolXp, masteryXp) {
		let adjustedInterval = currentInterval;
		switch (initial.skillID) {
			case CONSTANTS.skill.Mining:
				// compute max rock HP
				let rockHP = 5 /*base*/ + convertXpToLvl(masteryXp);
				if (petUnlocked[4]) {
					rockHP += 5;
				}
				if (poolXp >= initial.poolLim[3]) {
					rockHP += 10;
				}
				// potions can preserve rock HP
				let preservation = herbloreBonuses[10].bonus[1]
				if (preservation !== null) {
					rockHP /= (1 - preservation / 100);
				}
				// compute average time per action
				let spawnTime = miningData[initial.currentAction].respawnInterval;
				adjustedInterval = (adjustedInterval * rockHP + spawnTime) / rockHP;
				break;

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

	// Adjust preservation chance based on unlocked bonuses
	function poolPreservation(initial, poolXp) {
		let preservation = 0;
		switch (initial.skillID) {
			case CONSTANTS.skill.Smithing:
				if (poolXp >= initial.poolLim[1]) preservation += 5;
				if (poolXp >= initial.poolLim[2]) preservation += 5;
				break;

			case CONSTANTS.skill.Runecrafting:
				if (poolXp >= initial.poolLim[2]) preservation += 10;
				break;

			case CONSTANTS.skill.Herblore:
				if (poolXp >= initial.poolLim[2]) preservation += 5;
				break;

			case CONSTANTS.skill.Cooking:
				if (poolXp >= initial.poolLim[2]) preservation += 10;
				break;
		}
		return preservation / 100;
	}

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

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

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

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

	// Calculate total number of unlocked items for skill based on current skill level
	function calcTotalUnlockedItems(skillID, skillXp) {
		let count = 0;
		let currentSkillLevel = convertXpToLvl(skillXp);
		for (let i = 0; i < MILESTONES[skillName[skillID]].length; i++) {
			if (currentSkillLevel >= MILESTONES[skillName[skillID]][i].level) count++;
		}
		return count;
	}

	// compute average actions per mastery token
	function actionsPerToken(skillID, skillXp) {
		let actions = 20000 / calcTotalUnlockedItems(skillID, skillXp);
		if (equippedItems.includes(CONSTANTS.item.Clue_Chasers_Insignia)) {
			actions *= 0.9;
		}
		return actions;
	}

	function initialVariables(skillID) {
		let initial = {
			skillID: skillID,
			item: 0,
			itemXp: 0,
			skillInterval: 0,
			masteryID: 0,
			skillXp: skillXP[skillID], // Current skill Xp
			masteryXp: 0, // Current amount of Mastery experience
			totalMasteryLevel: 0,
			poolXp: 0,
			maxPoolXp: 0,
			targetPoolXp: 0,
			masteryLim: [], // Xp needed to reach next level
			skillLim: [], // Xp needed to reach next level
			poolLim: [], // Xp need to reach next pool checkpoint
			skillReq: [], // Needed items for craft and their quantities
			recordCraft: Infinity, // Amount of craftable items for limiting resource
			isMagic: skillID === CONSTANTS.skill.Magic, // magic has no mastery, so we often check this
			// gathering skills are treated differently, so we often check this
			isGathering: skillID === CONSTANTS.skill.Woodcutting
				|| skillID === CONSTANTS.skill.Fishing
				|| skillID === CONSTANTS.skill.Mining
				|| skillID === CONSTANTS.skill.Thieving,
			// Generate default values for script
			poolLimCheckpoints: [10, 25, 50, 95, 100, Infinity], //Breakpoints for mastery pool bonuses followed by Infinity
			maxXp: convertLvlToXp(ETASettings.getTargetLevel(skillID)),
			maxMasteryXp: convertLvlToXp(ETASettings.getTargetMastery(skillID)),
			tokens: 0,
		}
		//Breakpoints for mastery bonuses - default all levels starting at 2 to 99, followed by Infinity
		initial.masteryLimLevel = Array.from({ length: 98 }, (_, i) => i + 2);
		initial.masteryLimLevel.push(Infinity);
		//Breakpoints for mastery 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);
		// Chance to keep at breakpoints - default 0.2% per level
		initial.chanceToKeep = Array.from({ length: 99 }, (_, i) => i *0.002);
		initial.chanceToKeep[98] += 0.05; // Level 99 Bonus
		return initial;
	}

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

	function configureSmithing(initial) {
		initial.item = smithingItems[selectedSmith].itemID;
		initial.itemXp = items[initial.item].smithingXP;
		initial.skillInterval = 2000;
		if (godUpgrade[3]) initial.skillInterval *= 0.8;
		for (let i of items[initial.item].smithReq) {
			initial.skillReq.push(i);
		}
		initial.masteryLimLevel = [20, 40, 60, 80, 99, Infinity]; // Smithing Mastery Limits
		initial.chanceToKeep = [0, 0.05, 0.10, 0.15, 0.20, 0.30]; //Smithing Mastery bonus percentages
		if (petUnlocked[5]) initial.chanceToKeep = initial.chanceToKeep.map(n => n + PETS[5].chance / 100); // Add Pet Bonus
		return initial;
	}

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

	function configureRunecrafting(initial) {
		initial.item = runecraftingItems[selectedRunecraft].itemID;
		initial.itemXp = items[initial.item].runecraftingXP;
		initial.skillInterval = 2000;
		if (godUpgrade[1]) initial.skillInterval *= 0.8;
		for (let i of items[initial.item].runecraftReq) {
			initial.skillReq.push(i);
		}
		initial.masteryLimLevel = [99, Infinity]; // Runecrafting has no Mastery bonus
		initial.chanceToKeep = [0, 0]; //Thus no chance to keep
		if (skillCapeEquipped(CONSTANTS.item.Runecrafting_Skillcape)) {
			initial.chanceToKeep[0] += 0.35;
		}
		if (petUnlocked[10]) initial.chanceToKeep[0] += PETS[10].chance / 100;
		initial.chanceToKeep[1] = initial.chanceToKeep[0];
		return initial;
	}

	function configureCrafting(initial) {
		initial.item = craftingItems[selectedCraft].itemID;
		initial.itemXp = items[initial.item].craftingXP;
		initial.skillInterval = 3000;
		if (godUpgrade[0]) initial.skillInterval *= 0.8;
		if (skillCapeEquipped(CONSTANTS.item.Crafting_Skillcape)) {
			initial.skillInterval -= 500;
		}
		if (petUnlocked[9]) initial.skillInterval -= 200;
		items[initial.item].craftReq.forEach(i=>initial.skillReq.push(i));
		return initial;
	}

	function configureHerblore(initial){
		initial.item = herbloreItemData[selectedHerblore].itemID[getHerbloreTier(selectedHerblore)];
		initial.itemXp = herbloreItemData[selectedHerblore].herbloreXP;
		initial.skillInterval = 2000;
		if (godUpgrade[1]) initial.skillInterval *= 0.8;
		for (let i of items[initial.item].herbloreReq) {
			initial.skillReq.push(i);
		}
		return initial;
	}

	function configureCooking(initial) {
		initial.item = selectedFood;
		initial.itemXp = items[initial.item].cookingXP;
		if (currentCookingFire > 0) {
			initial.itemXp *= (1 + cookingFireData[currentCookingFire - 1].bonusXP / 100);
		}
		initial.skillInterval = 3000;
		if (godUpgrade[3]) initial.skillInterval *= 0.8;
		initial.skillReq = [{id: initial.item, qty: 1}];
		initial.masteryLimLevel = [99, Infinity]; //Cooking has no Mastery bonus
		initial.chanceToKeep = [0, 0]; //Thus no chance to keep
		initial.item = items[initial.item].cookedItemID;
		return initial;
	}

	function configureFiremaking(initial) {
		initial.item = selectedLog;
		initial.itemXp = logsData[selectedLog].xp * (1 + bonfireBonus / 100);
		initial.skillInterval = logsData[selectedLog].interval;
		if (godUpgrade[3]) initial.skillInterval *= 0.8;
		initial.skillReq = [{id: initial.item, qty: 1}];
		initial.chanceToKeep.fill(0); // Firemaking Mastery does not provide preservation chance
		return initial;
	}

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

	function configureGathering(initial) {
		initial.skillReq = [];
		initial.chanceToKeep = initial.chanceToKeep.map(_ => 0); // No chance to keep for gathering
		initial.recordCraft = 0;
		initial.masteryID = initial.currentAction;
		return initial;
	}

	function configureMining(initial) {
		initial.item = miningData[initial.currentAction].ore;
		initial.itemXp = items[initial.item].miningXP;
		initial.skillInterval = 3000;
		if (godUpgrade[2]) initial.skillInterval *= 0.8;
		initial.skillInterval *= 1 - pickaxeBonusSpeed[currentPickaxe] / 100;
		return configureGathering(initial);
	}

	function configureThieving(initial) {
		initial.item = thievingNPC[initial.currentAction];
		initial.itemXp = initial.item.xp;
		initial.skillInterval = 3000;
		if (skillCapeEquipped(CONSTANTS.item.Thieving_Skillcape)) {
			initial.skillInterval -= 500;
		}
		return configureGathering(initial);
	}

	function configureWoodcutting(initial) {
		initial.item = trees[initial.currentAction];
		initial.itemXp = initial.item.xp;
		initial.skillInterval = initial.item.interval;
		if (godUpgrade[2]) {
			initial.skillInterval *= 0.8;
		}
		initial.skillInterval *= 1 - axeBonusSpeed[currentAxe] / 100;
		if (skillCapeEquipped(CONSTANTS.item.Woodcutting_Skillcape)) {
			initial.skillInterval /= 2;
		}
		return configureGathering(initial);
	}

	function configureFishing(initial) {
		initial.item = items[fishingItems[fishingAreas[initial.currentAction].fish[initial.fishID]].itemID];
		initial.itemXp = initial.item.fishingXP;
		// base avg interval
		let avgRoll = 0.5;
		const max = initial.item.maxFishingInterval;
		const min = initial.item.minFishingInterval;
		initial.skillInterval = Math.floor(avgRoll * (max - min)) + min;
		// handle gear and rod
		let fishingAmuletBonus = 1;
		if (equippedItems.includes(CONSTANTS.item.Amulet_of_Fishing)) {
			fishingAmuletBonus = 1 - items[CONSTANTS.item.Amulet_of_Fishing].fishingSpeedBonus / 100;
		}
		initial.skillInterval = Math.floor(initial.skillInterval * fishingAmuletBonus * (1 - rodBonusSpeed[currentRod] / 100));
		initial = configureGathering(initial);
		// correctly set masteryID
		initial.masteryID = fishingAreas[initial.currentAction].fish[initial.fishID];
		return initial
	}

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

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

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

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

	function currentVariables(initial, resources) {
		let current = {
			sumTotalTime: 0,
			// skill
			skillXp: initial.skillXp,
			maxSkillReached: initial.skillXp >= initial.maxXp,
			maxSkillTime: 0,
			maxSkillResources: 0,
			// mastery
			masteryXp: initial.masteryXp,
			maxMasteryReached: initial.masteryXp >= initial.maxMasteryXp,
			maxMasteryTime: 0,
			maxMasteryResources: 0,
			// pool
			poolXp: initial.poolXp,
			maxPoolReached: initial.poolXp >= initial.targetPoolXp,
			maxPoolTime: 0,
			maxPoolResources: 0,
			totalMasteryLevel: initial.totalMasteryLevel,
			// items
			resources: resources,
			chargeUses: 0, // estimated remaining charge uses
			tokens: initial.tokens,
			// estimated number of actions taken so far
			actions: 0,
		};
		return current;
	}

	function calcTimeToBreakpoint(initial, current, noResources = false) {
		const rhaelyxChargePreservation = 0.15;

		// Adjustments
		const totalChanceToUse = 1 - masteryPreservation(initial, current.masteryXp, initial.chanceToKeep) - poolPreservation(initial, current.poolXp);
		const currentInterval = intervalAdjustment(initial, current.poolXp, current.masteryXp);
		const averageActionTime = intervalRespawnAdjustment(initial, currentInterval, current.poolXp, current.masteryXp);

		// Current Xp
		const xpPerAction = skillXpAdjustment(initial, current.poolXp, current.masteryXp);
		const masteryXpPerAction = calcMasteryXpToAdd(initial, current, currentInterval);
		let poolXpPerAction = calcPoolXpToAdd(current.skillXp, masteryXpPerAction);
		const tokensPerAction = 1 / actionsPerToken(initial.skillID, current.skillXp);
		const tokenXpPerAction = initial.maxPoolXp / 1000 * tokensPerAction;
		if (ETASettings.USE_TOKENS) {
			poolXpPerAction += tokenXpPerAction;
		}

		// Distance to Limits
		getLim = (lims, xp, max) => {
			const lim = lims.find(element => element > xp);
			if (xp < max && max < lim) {
				return Math.ceil(max);
			}
			return Math.ceil(lim);
		}
		const skillXpToLimit = getLim(initial.skillLim, current.skillXp, initial.maxXp) - current.skillXp;
		const masteryXpToLimit = getLim(initial.skillLim, current.masteryXp, initial.maxMasteryXp) - current.masteryXp;
		const poolXpToLimit = getLim(initial.poolLim, current.poolXp, initial.targetPoolXp) - current.poolXp;

		// Actions to limits
		const skillXpActions = skillXpToLimit / xpPerAction;
		const masteryXpActions = masteryXpToLimit / masteryXpPerAction;
		const poolXpActions = poolXpToLimit / poolXpPerAction;

		// Minimum actions based on limits
		let expectedActions = Math.ceil(Math.min(masteryXpActions, skillXpActions, poolXpActions));

		// estimate actions remaining with current resources
		let resourceActions = 0;
		if (!noResources) {
			// estimate amount of actions possible with remaining resources
			// number of actions with rhaelyx charges
			resourceActions = Math.min(current.chargeUses, current.resources / (totalChanceToUse - rhaelyxChargePreservation));
			// remaining resources
			const resWithoutCharge = Math.max(0, current.resources - current.chargeUses * (totalChanceToUse - rhaelyxChargePreservation));
			// add number of actions without rhaelyx charges
			resourceActions = Math.ceil(resourceActions + resWithoutCharge / totalChanceToUse);
			expectedActions = Math.min(expectedActions, resourceActions);
			// estimate total remaining actions
			current.actions += expectedActions;
		}
		// Take away resources based on expectedActions
		if (!initial.isGathering) {
			// Update remaining Rhaelyx Charge uses
			current.chargeUses -= expectedActions;
			if (current.chargeUses < 0) {
				current.chargeUses = 0;
			}
			// Update remaining resources
			if (expectedActions === resourceActions) {
				current.resources = 0; // No more limits
			} else {
				let resUsed = 0;
				if (expectedActions < current.chargeUses) {
					// won't run out of charges yet
					resUsed = expectedActions * (totalChanceToUse - rhaelyxChargePreservation);
				} else {
					// first use charges
					resUsed = current.chargeUses * (totalChanceToUse - rhaelyxChargePreservation);
					// remaining actions are without charges
					resUsed += (expectedActions - current.chargeUses) * totalChanceToUse;
				}
				current.resources = Math.round(current.resources - resUsed);
			}
		}
		// time for current loop
		const timeToAdd = expectedActions * averageActionTime;
		// gain tokens, unless we're using them
		if (!ETASettings.USE_TOKENS) {
			current.tokens += expectedActions * tokensPerAction;
		}
		// Update time and Xp
		current.sumTotalTime += timeToAdd;
		current.skillXp += xpPerAction * expectedActions;
		current.masteryXp += masteryXpPerAction * expectedActions;
		current.poolXp += poolXpPerAction * expectedActions;
		// Time for target skill level, 99 mastery, and 100% pool
		if (!current.maxSkillReached && initial.maxXp <= current.skillXp) {
			current.maxSkillTime = current.sumTotalTime;
			current.maxSkillReached = true;
			current.maxSkillResources = initial.recordCraft - current.resources;
		}
		if (!current.maxMasteryReached && initial.maxMasteryXp <= current.masteryXp) {
			current.maxMasteryTime = current.sumTotalTime;
			current.maxMasteryReached = true;
			current.maxMasteryResources = initial.recordCraft - current.resources;
		}
		if (!current.maxPoolReached && initial.targetPoolXp <= current.poolXp) {
			current.maxPoolTime = current.sumTotalTime;
			current.maxPoolReached = true;
			current.maxPoolResources = initial.recordCraft - current.resources;
		}
		// Level up mastery if hitting Mastery limit
		if (expectedActions === masteryXpActions) {
			current.totalMasteryLevel++;
		}
		// return updated values
		return current;
	}

	// Calculates expected time, taking into account Mastery Level advancements during the craft
	function calcExpectedTime(initial, resources) {
		// initialize the expected time variables
		let current = currentVariables(initial, resources);
		// Check for Crown of Rhaelyx
		if (equippedItems.includes(CONSTANTS.item.Crown_of_Rhaelyx) && !initial.isMagic) {
			for (let i = 0; i < initial.masteryLimLevel.length; i++) {
				initial.chanceToKeep[i] += 0.10; // Add base 10% chance
			}
			let rhaelyxCharge = getQtyOfItem(CONSTANTS.item.Charge_Stone_of_Rhaelyx);
			current.chargeUses = rhaelyxCharge * 1000; // average crafts per Rhaelyx Charge Stone
		}
		// loop until out of resources
		while (current.resources > 0) {
			current = calcTimeToBreakpoint(initial, current);
		}
		let xpH, masteryXpH, poolH, tokensH;
		if (ETASettings.CURRENT_RATES || initial.isGathering ) {
			// compute current xp/h and mxp/h
			const initialInterval = intervalAdjustment(initial, initial.poolXp, initial.masteryXp);
			const initialAverageActionTime = intervalRespawnAdjustment(initial, initialInterval, initial.poolXp, initial.masteryXp);
			xpH = skillXpAdjustment(initial, initial.poolXp, initial.masteryXp) / initialAverageActionTime * 1000 * 3600;
			// 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, initialInterval);
			masteryXpH = masteryXpPerAction / initialAverageActionTime * 1000 * 3600;
			// pool percentage per hour
			poolH = calcPoolXpToAdd(current.skillXp, masteryXpPerAction) / initialAverageActionTime * 1000 * 3600 / initial.maxPoolXp;
			tokensH = 3600 * 1000 / initialAverageActionTime / actionsPerToken(initial.skillID, initial.skillXp);
		} else {
			// compute average (mastery) xp/h until resources run out
			xpH = (current.skillXp - initial.skillXp) * 3600 * 1000 / current.sumTotalTime;
			masteryXpH = (current.masteryXp - initial.masteryXp) * 3600 * 1000 / current.sumTotalTime;
			// average pool percentage per hour
			poolH = (current.poolXp - initial.poolXp) * 3600 * 1000 / current.sumTotalTime / initial.maxPoolXp;
			tokensH = (current.tokens - initial.tokens) * 3600 * 1000 / current.sumTotalTime;
		}
		// each token contributes one thousandth of the pool and then convert to percentage
		poolH = (poolH + tokensH / 1000) * 100;
		// 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),
			"actions": current.actions,
			"finalSkillXp" : current.skillXp,
			"finalMasteryXp" : current.masteryXp,
			"finalPoolPercentage" : poolXpToPercentage(current.poolXp),
			"maxPoolTime" : current.maxPoolTime,
			"maxMasteryTime" : current.maxMasteryTime,
			"maxSkillTime" : current.maxSkillTime,
			"xpH": xpH,
			"masteryXpH": masteryXpH,
			"poolH": poolH,
			"tokens": current.tokens,
		};
		// continue calculations until time to all targets is found
		while(!current.maxSkillReached || !current.maxMasteryReached || !current.maxPoolReached) {
			current = calcTimeToBreakpoint(initial, current, true);
		}
		// if it is a gathering skill, then set final values to the values when reaching the final target
		if (initial.isGathering) {
			expectedTime.finalSkillXp = current.skillXp;
			expectedTime.finalMasteryXp = current.masteryXp;
			expectedTime.finalPoolPercentage = poolXpToPercentage(current.poolXp);
			expectedTime.tokens = current.tokens;
		}
		// set time to targets
		expectedTime.maxSkillTime = current.maxSkillTime;
		expectedTime.maxMasteryTime = current.maxMasteryTime;
		expectedTime.maxPoolTime = current.maxPoolTime;
		// return the resulting data object
		expectedTime.current = current;
		return expectedTime;
	}

	function timeRemainingWrapper(skillID) {
		// populate the main `time remaining` variables
		let initial = initialVariables(skillID);
		if (initial.isGathering) {
			let data = [];
			switch (initial.skillID) {
				case CONSTANTS.skill.Mining:
					data = miningData;
					break;

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

				case CONSTANTS.skill.Woodcutting:
					data = trees;
					break;

				case CONSTANTS.skill.Fishing:
					data = fishingAreas;
					break;
			}
			data.forEach((_, i) => {
				if (initial.skillID === CONSTANTS.skill.Fishing) {
					initial.fishID = selectedFish[i];
					if (initial.fishID === null) {
						return;
					}
				}
				initial.currentAction = i;
				timeRemaining(initial)
			});
		} else {
			timeRemaining(initial);
		}
	}

	// Main function
	function timeRemaining(initial) {
		// Set current skill and pull matching variables from game with script
		switch (initial.skillID) {
			case CONSTANTS.skill.Smithing:
				initial = configureSmithing(initial);
				break;
			case CONSTANTS.skill.Fletching:
				initial = configureFletching(initial);
				break;
			case CONSTANTS.skill.Runecrafting:
				initial = configureRunecrafting(initial);
				break;
			case CONSTANTS.skill.Crafting:
				initial = configureCrafting(initial);
				break;
			case CONSTANTS.skill.Herblore:
				initial = configureHerblore(initial);
				break;
			case CONSTANTS.skill.Cooking:
				initial = configureCooking(initial);
				break;
			case CONSTANTS.skill.Firemaking:
				initial = configureFiremaking(initial);
				break;
			case CONSTANTS.skill.Magic:
				initial = configureMagic(initial);
				break;
			case CONSTANTS.skill.Mining:
				initial = configureMining(initial);
				break;
			case CONSTANTS.skill.Thieving:
				initial = configureThieving(initial);
				break;
			case CONSTANTS.skill.Woodcutting:
				initial = configureWoodcutting(initial);
				break;
			case CONSTANTS.skill.Fishing:
				initial = configureFishing(initial);
				break;
		}
		// Configure initial mastery values for all skills with masteries
		if (!initial.isMagic) {
			initial.poolXp = MASTERY[initial.skillID].pool;
			initial.maxPoolXp = getMasteryPoolTotalXP(initial.skillID);
			initial.targetPoolXp = initial.maxPoolXp;
			if (ETASettings.getTargetPool(initial.skillID) !== 100) {
				initial.targetPoolXp = initial.maxPoolXp / 100 * ETASettings.getTargetPool(initial.skillID);
			}
			initial.totalMasteryLevel = getCurrentTotalMasteryLevelForSkill(initial.skillID);
			if (!initial.isGathering) {
				initial.masteryID = items[initial.item].masteryID[1];
			}
			initial.masteryXp = MASTERY[initial.skillID].xp[initial.masteryID];
			initial.tokens = getQtyOfItem(CONSTANTS.item["Mastery_Token_" + skillName[initial.skillID]])
		}

		// Apply itemXp Bonuses from gear and pets
		initial.itemXp = addXPBonuses(initial.skillID, initial.itemXp);

		// 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
		for (let i = 0; i < initial.skillReq.length; i++) {
			let itemReq = initial.skillReq[i].qty;
			//Check how many of required resource in Bank
			let itemQty = getQtyOfItem(initial.skillReq[i].id);
			// Calculate max items you can craft for each itemReq
			let itemCraft = Math.floor(itemQty / itemReq);
			// Calculate limiting factor and set new record
			if (itemCraft < initial.recordCraft) {
				initial.recordCraft = itemCraft;
			}
		}

		//Time left
		let results = 0;
		let timeLeft = 0;
		let timeLeftPool = 0;
		let timeLeftMastery = 0;
		let timeLeftSkill = 0;
		let tokens = 0;
		let current = {};
		if (initial.isMagic) {
			timeLeft = Math.round(initial.recordCraft * initial.skillInterval / 1000);
		} else {
			results = calcExpectedTime(initial, initial.recordCraft);
			timeLeft = Math.round(results.timeLeft / 1000);
			timeLeftPool = Math.round(results.maxPoolTime / 1000);
			timeLeftMastery = Math.round(results.maxMasteryTime / 1000);
			timeLeftSkill = Math.round(results.maxSkillTime / 1000);
			tokens = Math.round(results.tokens);
			current = results.current;
		}

		//Global variables to keep track of when a craft is complete
		window.timeLeftLast = window.timeLeftCurrent;
		window.timeLeftCurrent = timeLeft;

		//Inject timeLeft HTML
		let now = new Date();
		let timeLeftElementId = "timeLeft".concat(skillName[initial.skillID]);
		if (initial.isGathering) {
			timeLeftElementId += "-" + initial.currentAction;
		}
		if (initial.skillID === CONSTANTS.skill.Thieving && document.getElementById(timeLeftElementId) === null) {
			makeThievingDisplay();
		}
		let timeLeftElement = document.getElementById(timeLeftElementId);
		if (timeLeftElement !== null) {
			let finishedTime = AddSecondsToDate(now, timeLeft);
			if (initial.isGathering) {
				timeLeftElement.textContent = "Xp/h: " + formatNumber(Math.floor(results.xpH))
					+ "\r\nMXp/h: " + formatNumber(Math.floor(results.masteryXpH))
					+ `\r\nPool/h: ${results.poolH.toFixed(2)}%`
			} else if (timeLeft === 0) {
				timeLeftElement.textContent = "No resources!";
			} else if (!ETASettings.SHOW_XP_RATE || initial.isMagic) {
				timeLeftElement.textContent = "Will take: " + secondsToHms(timeLeft) + "\r\n ETA: " + DateFormat(now, finishedTime);
			} else {
				timeLeftElement.textContent = "Xp/h: " + formatNumber(Math.floor(results.xpH))
					+ "\r\nMXp/h: " + formatNumber(Math.floor(results.masteryXpH))
					+ `\r\nPool/h: ${results.poolH.toFixed(2)}%`
					+ "\r\nActions: " + formatNumber(results.actions)
					+ "\r\nTime: " + secondsToHms(timeLeft)
					+ "\r\nETA: " + DateFormat(now, finishedTime);
			}
			timeLeftElement.style.display = "block";
		}
		if (!initial.isMagic) {
			// Generate progression Tooltips
			if (!timeLeftElement._tippy) {
				tippy(timeLeftElement, {
					allowHTML: true,
					interactive: false,
					animation: false,
				});
			}
			const wrapOpen = '<div class="row no-gutters">';
			const wrapFirst = s => {
				return ''
					+ '<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;">`
					+ s
					+ '        </span>'
					//+ '    </h3>'
					+ '</div>';
			}
			const wrapSecond = (tag, s) => {
				return ''
					+ '<div class="col-6" style="white-space: nowrap;">'
					+ '    <h3 class="font-size-base m-1" style="color:white;" >'
					+ `        <span class="p-1 bg-${tag} rounded" style="text-align:center; display: inline-block;line-height: normal;width: 100px;color:white;">`
					+ s
					+ '        </span>'
					+ '    </h3>'
					+ '</div>';
			}
			const timeLeftToHTML = (target, time, finish, resources) => {
				return ''
					+ `Time to ${target}: ${time}`
					+ '<br>'
					+ `ETA: ${finish}`
					+ resourcesLeftToHTML(resources);
			}
			const resourcesLeftToHTML = (resources) => {
				if (ETASettings.HIDE_REQUIRED || initial.isGathering || resources === 0) {
					return '';
				}
				let req = initial.skillReq.map(x =>
					`<span>${formatNumber(x.qty * resources)}</span><img class="skill-icon-xs mr-2" src="${items[x.id].media}">`
				).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 wrapClose = '</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;
			}
			let finalLevel = convertXpToLvl(results.finalSkillXp, true)
			let levelProgress = getPercentageInLevel(results.finalSkillXp, results.finalSkillXp, "skill");
			finalLevel = formatLevel(finalLevel, levelProgress);
			let finalSkillLevelElement = wrapOpen + wrapFirst('Final Level') + wrapSecond('success', finalLevel + ' / 99') + wrapClose;
			let timeLeftSkillElement = '';
			if (timeLeftSkill > 0) {
				let finishedTimeSkill = AddSecondsToDate(now, timeLeftSkill);
				timeLeftSkillElement = wrapTimeLeft(
					timeLeftToHTML(
						ETASettings.getTargetLevel(initial.skillID),
						secondsToHms(timeLeftSkill),
						DateFormat(now, finishedTimeSkill),
						current.maxSkillResources,
					),
				);
			}
			let finalMastery = convertXpToLvl(results.finalMasteryXp);
			let masteryProgress = getPercentageInLevel(results.finalMasteryXp, results.finalMasteryXp, "mastery");
			finalMastery = formatLevel(finalMastery, masteryProgress);
			let finalMasteryLevelElement = wrapOpen + wrapFirst('Final Mastery') + wrapSecond('info', finalMastery + ' / 99') + wrapClose;
			let timeLeftMasteryElement = '';
			if (timeLeftMastery > 0) {
				let finishedTimeMastery = AddSecondsToDate(now, timeLeftMastery);
				timeLeftMasteryElement = wrapTimeLeft(
					timeLeftToHTML(
						ETASettings.getTargetMastery(initial.skillID),
						secondsToHms(timeLeftMastery),
						DateFormat(now, finishedTimeMastery),
						current.maxMasteryResources,
					),
				);
			}
			const finalPoolPercentageElement = wrapOpen + wrapFirst('Final Pool XP') + wrapSecond('warning', results.finalPoolPercentage + '%') + wrapClose;
			let timeLeftPoolElement = '';
			if (tokens > 0 || timeLeftPool > 0) {
				let finishedTimePool = AddSecondsToDate(now, timeLeftPool);
				timeLeftPoolElement = wrapTimeLeft(
					(tokens === 0
							? ''
							: `Final token count: ${tokens}`
					)
					+ (tokens === 0 || timeLeftPool === 0 ? '' : '<br>')
					+ (timeLeftPool === 0
						? ''
						: timeLeftToHTML(
							`${ETASettings.getTargetPool(initial.skillID)}%`,
							secondsToHms(timeLeftPool),
							DateFormat(now, finishedTimePool),
							current.maxPoolResources,
						)
					),
				);
			}
			let tooltip = ''
				+ '<div>'
				+ finalSkillLevelElement
				+ timeLeftSkillElement
				+ finalMasteryLevelElement
				+ timeLeftMasteryElement
				+ finalPoolPercentageElement
				+ timeLeftPoolElement
				+ '</div>';
			timeLeftElement._tippy.setContent(tooltip);

			{
				let 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 + "%");
				let masteryProgress = getPercentageInLevel(initial.masteryXp, results.finalMasteryXp, "mastery", true);
				$(`#${initial.skillID}-mastery-pool-progress-end`).css("width", masteryProgress + "%");
				let skillProgress = getPercentageInLevel(initial.skillXp, results.finalSkillXp, "skill", true);
				$(`#skill-progress-bar-end-${initial.skillID}`).css("width", skillProgress + "%");
			}
		}
	}

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

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

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

	$("#smith-item-have").after(tempContainer("timeLeftSmithing"));
	$("#fletch-item-have").after(tempContainer("timeLeftFletching"));
	$("#runecraft-item-have").after(tempContainer("timeLeftRunecrafting"));
	$("#craft-item-have").after(tempContainer("timeLeftCrafting"));
	$("#herblore-item-have").after(tempContainer("timeLeftHerblore"));
	$("#skill-cooking-food-selected-qty").parent().parent().parent().after(tempContainer("timeLeftCooking"));
	$("#skill-fm-logs-selected-qty").parent().parent().parent().after(tempContainer("timeLeftFiremaking"));
	$("#magic-item-have-and-div").after(tempContainer("timeLeftMagic"));
	function makeMiningDisplay() {
		miningData.forEach((_, i) => {
			$(`#mining-ore-img-${i}`).before(tempContainer(`timeLeftMining-${i}`))
		});
	}
	makeMiningDisplay();
	function makeThievingDisplay() {
		thievingNPC.forEach((_, i) => {
			$(`#success-rate-${i}`).parent().after(tempContainer(`timeLeftThieving-${i}`))
		});
	}
	makeThievingDisplay(); // this has to be a function because in some scenarios the thieving display disappears, so we need to remake it
	function makeWoodcuttingDisplay() {
		trees.forEach((_, i) => {
			$(`#tree-rates-${i}`).after(tempContainer(`timeLeftWoodcutting-${i}`))
		});
	}
	makeWoodcuttingDisplay();
	function makeFishingDisplay() {
		fishingAreas.forEach((_, i) => {
			$(`#fishing-area-${i}-selected-fish-xp`).after(tempContainer(`timeLeftFishing-${i}`))
		});
	}
	makeFishingDisplay();

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

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

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

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

	function loadScript() {
		if ((window.isLoaded && !window.currentlyCatchingUp)
			|| (typeof unsafeWindow !== 'undefined' && unsafeWindow.isLoaded && !unsafeWindow.currentlyCatchingUp)) {
			// Only load script after game has opened
			clearInterval(scriptLoader);
			injectScript(script);
			// load settings from local storage
			if (localStorage['ETASettings'] !== undefined) {
				const stored = window.JSON.parse(localStorage['ETASettings']);
				Object.getOwnPropertyNames(stored).forEach(x => {
					ETASettings[x] = stored[x];
				});
				ETASettings.save();
			}
			// regularly save settings to local storage
			setInterval(ETASettings.save, 1000)
		}
	}

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