您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows xp/h and mastery xp/h, and the time remaining until certain targets are reached. Takes into account Mastery Levels and other bonuses.
// ==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); });