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