// ==UserScript==
// @name Queslar Betterment Script
// @namespace https://www.queslar.com
// @version 1.8.0
// @description A script that lets you know more info about quests and other QOL improvements
// @author RiddleMeDoo
// @match *://*.queslar.com/*
// @require https://code.jquery.com/jquery-3.6.3.slim.min.js
// @resource settingsMenu https://raw.githubusercontent.com/RiddleMeDoo/qs-bettermentScript/master/tomeSettingsMenu.html
// @grant GM_getResourceText
// ==/UserScript==
class Script {
constructor() {
// Get quest data
this.quest = {
questsCompleted: 0,
numRefreshes: 0,
refreshesUsed: 0,
villageBold: 0,
villageSize: 1,
villageNumRefreshes: 5,
villageRefreshesUsed: 0,
baseStat: 15,
minActions: 360,
maxActions: 580,
};
this.catacomb = {
actionTimerSeconds: 30,
tomesAreEquipped: true,
}
this.kdExploLevel = 0;
this.playerId;
this.gameData;
this.gems = [];
//observer setup
this.initObservers();
this.currentPath = window.location.hash.split('/').splice(2).join();
// Other setup
addInvisibleScrollDiv();
}
loadDataFromStorage() {
/**
* Load data stored in the localStorage of the website. Each player stores their own settings.
*/
// ! BANDAID migration, please remove non-id settings in storage after 2024-12-01
this.villageSettings = JSON.parse(localStorage.getItem(`${this.playerId}:QuesBS_villageSettings`));
if (!this.villageSettings && localStorage.getItem('QuesBS_villageSettings')) {
// Attempt migration from old settings
this.villageSettings = JSON.parse(localStorage.getItem('QuesBS_villageSettings'));
localStorage.setItem(`${this.playerId}:QuesBS_villageSettings`, JSON.stringify(this.villageSettings));
localStorage.removeItem('QuesBS_villageSettings');
} else if(!this.villageSettings) {
this.villageSettings = {
strActions: 30000,
taxObjectivePercent: 0,
resActionRatio: 999999999999,
};
}
this.tomeSettings = JSON.parse(localStorage.getItem(`${this.playerId}:QuesBS_tomeSettings`));
if (!this.tomeSettings && localStorage.getItem('QuesBS_tomeSettings')) {
// Attempt migration from old settings
this.tomeSettings = JSON.parse(localStorage.getItem('QuesBS_tomeSettings'));
localStorage.setItem(`${this.playerId}:QuesBS_tomeSettings`, JSON.stringify(this.tomeSettings));
localStorage.removeItem('QuesBS_tomeSettings');
} else if(!this.tomeSettings) {
this.tomeSettings = {
goldKillTomesEquippedAmount: 0,
useWeightSettings: false,
weights: {},
thresholds: {
reward: 999900,
mobDebuff: 999900,
character: 999900,
characterWb: 999900,
elementalConv: 999900,
multiMob: 1,
lifesteal: 1,
actionSpeed: 1,
mobSkip: 1,
numGoodRolls: 1,
numGoodRollsWb: 2,
},
spaceThresholds: {
reward: 6,
mobDebuff: 6,
character: 6,
wb: 6,
rare: 6,
legendary: 6,
},
hideMods: {},
disableRefreshOnHighlight: true,
};
}
// Legacy code updates, keep for now until bugs do not occur when it gets removed
this.tomeSettings.useWeightSettings = this.tomeSettings.useWeightSettings ?? false;
this.tomeSettings.weights = this.tomeSettings.weights ?? {};
this.tomeSettings.thresholds = this.tomeSettings.thresholds ?? {
reward: this.tomeSettings.highlightReward ?? 99900,
mobDebuff: this.tomeSettings.highlightMob ?? 99900,
character: this.tomeSettings.highlightCharacter ?? 99900,
characterWb: this.tomeSettings.highlightCharacterWb ?? 99900,
elementalConv: this.tomeSettings.highlightElementalConv ?? 99900,
multiMob: this.tomeSettings.highlightMultiMob ?? 1,
lifesteal: this.tomeSettings.highlightLifesteal ?? 1,
actionSpeed: this.tomeSettings.highlightActionSpeed ?? 1,
mobSkip: this.tomeSettings.highlightMobSkip ?? 1,
numGoodRolls: this.tomeSettings.numGoodRolls ?? 1,
numGoodRollsWb: 2,
}
this.tomeSettings.spaceThresholds = this.tomeSettings.spaceThresholds ?? {
reward: this.tomeSettings.spaceLimitReward ?? 6,
mobDebuff: this.tomeSettings.spaceLimitMob ?? 6,
character: this.tomeSettings.spaceLimitCharacter ?? 6,
wb: this.tomeSettings.spaceLimitWb ?? 6,
rare: this.tomeSettings.spaceLimitRare ?? 6,
legendary: this.tomeSettings.spaceLimitLegendary ?? 6,
}
this.tomeSettings.hideMods = this.tomeSettings.hideMods ?? {};
this.tomeSettings.disableRefreshOnHighlight = this.tomeSettings.disableRefreshOnHighlight ?? true;
}
async getGameData() { //ULTIMATE POWER
let tries = 10;
//Get a reference to *all* the data the game is using, courtesy of Blah's exposed global object
this.gameData = playerGeneralService;
while(this.gameData === undefined && tries > 0) { //Power comes with a price; wait for it to load
await new Promise(resolve => setTimeout(resolve, 500))
this.gameData = playerGeneralService;
tries--;
}
if (tries <= 0) {
console.log('QuesBS: Could not load gameData.');
}
}
async updateCatacombData() {
/***
* Updates catacomb action timer in seconds and equipped tomes indicator
***/
// Wait until services load
while(this.gameData?.playerCatacombService === undefined || this.gameData?.playerVillageService === undefined) {
await new Promise(resolve => setTimeout(resolve, 200));
}
const tomes = this.gameData.playerCatacombService.calculateTomeOverview();
// Calculate catacomb action speed in seconds
let actionTimerSeconds = 24;
if (this.gameData.playerCatacombService.actionData) {
// If a catacomb is active, it will show how many seconds it has in an action
actionTimerSeconds = this.gameData.playerCatacombService.actionData.actionSpeed;
} else {
// If a catacomb is inactive, actionData is undefined. Manually calculate
const villageService = this.gameData.playerVillageService;
let villageActionSpeedBoost;
if (villageService?.isInVillage === true) {
const level = villageService?.buildings?.observatory?.amount ?? 0;
villageActionSpeedBoost = (Math.floor(level / 20) * Math.floor(level / 20 + 1) / 2 * 20 + (level % 20) * Math.floor(level / 20 + 1)) / 100;
} else {
villageActionSpeedBoost = 0;
}
actionTimerSeconds = 30 / (1 + villageActionSpeedBoost + tomes.speed / 100) + 0.2
}
this.catacomb = {
actionTimerSeconds: actionTimerSeconds,
tomesAreEquipped: tomes.mobs > 0,
}
}
async initPlayerData() {
// Make sure gameData is loaded before initializing player data
let loadingTries = 300;
while ((!this.gameData || this.gameData.loadingService.loading) && loadingTries > 0) {
if (!this.gameData) {
this.getGameData();
}
console.log('QuesBS: Waiting for game to load...');
await new Promise(resolve => setTimeout(resolve, 1000));
loadingTries--;
}
if (loadingTries <= 0) {
console.log('QuesBS: Could not load player data. Please refresh or manually restart the script.');
return;
}
//Couldn't find an easier method to get quest completions than a POST request
this.gameData.httpClient.post('/player/load/misc', {}).subscribe(
val => {
this.quest.questsCompleted = val.playerMiscData.quests_completed;
this.playerId = val.playerMiscData.player_id;
this.loadDataFromStorage();
},
response => console.log('QuesBS: POST request failure', response)
);
await this.updateRefreshes();
if(this.gameData.playerVillageService?.isInVillage === true) {
let villageService = this.gameData.playerVillageService;
//Wait for service to load
while(villageService === undefined) {
await new Promise(resolve => setTimeout(resolve, 200));
villageService = this.gameData.playerVillageService;
}
this.quest.villageBold = villageService.strengths.bold.amount;
this.quest.villageSize = villageService.general.members.length;
this.quest.villageNumRefreshes = villageService.general.dailyQuestsBought + 5;
this.quest.villageRefreshesUsed = villageService.general.dailyQuestsUsed;
}
//Can't be bothered to calculate it accurately using all 4 stats
this.quest.baseStat = Math.min(15, this.gameData.playerStatsService?.strength * 0.0025);
// Get catacomb data
await this.updateCatacombData();
// Get kd exploration level for wb drops
await this.updateKdInfo();
// Other misc player refreshing stuff
this.gems = [];
}
async getPartyActions() {
//A function to wait for party service to load
//And also to abstract the horribly long method
while(this.gameData?.partyService?.partyOverview?.partyInformation === undefined) {
await new Promise(resolve => setTimeout(resolve, 200));
}
return this.gameData.partyService.partyOverview.partyInformation[this.playerId].actions.daily_actions_remaining;
}
async updateRefreshes() {
//Only made a load waiter because script was having issues with not loading
while(this.gameData?.playerQuestService?.refreshesUsed === undefined) {
await new Promise(resolve => setTimeout(resolve, 200));
}
this.quest.numRefreshes = this.gameData.playerQuestService.refreshesBought + 20;
this.quest.refreshesUsed = this.gameData.playerQuestService.refreshesUsed;
}
async updateVillageRefreshes() {
let villageService = this.gameData.playerVillageService;
this.quest.villageNumRefreshes = villageService.general.dailyQuestsBought + 5;
this.quest.villageRefreshesUsed = villageService.general.dailyQuestsUsed;
const maxBuildingLevel = Object.values(villageService.buildings).reduce((currMax, building) => Math.max(currMax, building.amount), 0);
const buildingCost = villageService.calculateVillageBuildingUpgradeCost(maxBuildingLevel);
this.quest.villageMaxResCost = buildingCost[1].cost * 4 + (buildingCost?.[5]?.cost ?? 0);
}
async updateKdInfo() {
/** Only stores exploration information for wb drops */
let kdService = this.gameData.playerKingdomService;
// Wait for game to load data
while(kdService?.kingdomData?.explorations === undefined) {
await new Promise(resolve => setTimeout(resolve, 200));
}
this.kdExploLevel = kdService.kingdomData.explorations.level;
}
initObservers() {
/**
* Initialize observers which will be used to detect changes on
* each specific page when it updates.
*/
let scriptObject = this; //mutation can't keep track of this
this.personalQuestObserver = new MutationObserver(mutationsList => {
scriptObject.handlePersonalQuest(mutationsList[0]);
});
this.villageQuestObserver = new MutationObserver(mutationsList => {
scriptObject.handleVillageQuest(mutationsList[0]);
});
this.catacombObserver = new MutationObserver(mutationsList => {
this.handleCatacombPage(mutationsList[0]);
});
this.tomeObserver = new MutationObserver(mutationsList => {
this.handleCatacombTomeStore(mutationsList[0]);
});
this.wbDropsObserver = new MutationObserver(mutationsList => {
this.handleWbChestOpening(mutationsList[0]);
});
}
async initPathDetection() {
/**
* Initializes the event trigger that will watch for changes in the
* url path. This will allow us to determine which part of the
* script to activate on each specific page.
*/
let router = this.gameData?.router
//Wait for service to load
while(router === undefined && router?.events === undefined) {
await new Promise(resolve => setTimeout(resolve, 200));
router = this.gameData.router
}
this.gameData.router.events.subscribe(event => {
if(event.navigationTrigger) this.handlePathChange(event.url);
});
//Send a popup to player as feedback
this.gameData.snackbarService.openSnackbar('QuesBS has been loaded.');
}
async insertPlayerStatRatios(petDiv) {
/*
* Insert player stat ratios into the pet div by copy pasting from one of the existing
* boxes.
*/
// Copy existing box to match the css style
const statBoxElem = petDiv.children[1].children[2].cloneNode(true);
statBoxElem.firstChild.innerText = 'Player stat ratios';
const statsBody = statBoxElem.children[1];
const playerStatsElem = document.querySelector('app-inventory-menu > div > div:nth-child(3)');
const statRatios = getStatRatios(playerStatsElem);
// Insert the stat ratios
for (let i = 0; i < statRatios.length; i++) {
const row = statsBody.children[i].firstChild;
row.children[1].innerText = `${playerStatsElem.children[i].children[1].innerText}`;
const statRatioDiv = document.createElement('div');
statRatioDiv.innerText = `(${statRatios[i]})`;
row.appendChild(statRatioDiv);
}
// Insert elem to be under the pet farm column
petDiv.children[2].appendChild(statBoxElem);
}
async handlePathChange(url) {
/**
* Detects which page the player navigated to when the url path
* has changed, then activates the observer for the page.
*/
const path = url.split('/').length == 2 ? url.split('/').slice(1) : url.split('/').slice(2);
if(path.join() !== this.currentPath) {
this.stopObserver(this.currentPath);
}
this.currentPath = path.join();
//Activate observer if on a specific page
if(path[path.length - 1].toLowerCase() === 'quests' && path[0].toLowerCase() === 'actions') {
//Observe personal quest page for updates
let target = document.querySelector('app-actions');
//Sometimes the script attempts to search for element before it loads in
while(!target) {
await new Promise(resolve => setTimeout(resolve, 50))
target = document.querySelector('app-actions');
}
this.personalQuestObserver.observe(target, {
childList: true, subtree: true, attributes: false,
});
//Sometimes there is no change observed for the initial page load, so call function
await this.handlePersonalQuest({target: target});
} else if(path[path.length - 1].toLowerCase() === 'quests' && path[0].toLowerCase() === 'village') {
//Observe village quest page for updates
let target = document.querySelector('app-village');
//Sometimes the script attempts to search for element before it loads in
while(!target) {
await new Promise(resolve => setTimeout(resolve, 50))
target = document.querySelector('app-village');
}
this.villageQuestObserver.observe(target, {
childList: true, subtree: true, attributes: false,
});
//Sometimes there is no change observed for the initial page load, so call function
await this.handleVillageQuest({target: target});
} else if(path[path.length - 1].toLowerCase() === 'settings' && path[0].toLowerCase() === 'village') {
//Insert our own settings box
await this.insertVillageSettingsElem();
} else if(path[path.length - 1].toLowerCase() === 'catacomb' && path[0].toLowerCase() === 'catacombs') {
this.updateCatacombData();
let target = document.querySelector('app-catacomb-main')?.firstChild;
while(!target) {
await new Promise(resolve => setTimeout(resolve, 200))
target = document.querySelector('app-catacomb-main').firstChild;
}
if (target.nodeName === '#comment') { // Active catacomb page
// Only listen for change in active/inactive state
this.catacombObserver.observe(target.parentElement, {
childList: true, subtree: false, attributes: false,
});
this.handleCatacombPage({target: target});
} else {
this.catacombObserver.observe(target, {
childList: true, subtree: true, attributes: false,
});
}
} else if (path[path.length - 1].toLowerCase() === 'tome_store' && path[0].toLowerCase() === 'catacombs') {
await this.modifyTomeStorePage();
let target = $('app-catacomb-tome-store > div > div > div.base-scrollbar > div');
while(target.length < 1) {
await new Promise(resolve => setTimeout(resolve, 200))
target = $('app-catacomb-tome-store > div > div > div.base-scrollbar > div');
}
this.tomeObserver.observe(target[0], {
childList: true, subtree: false, attributes: false
});
this.handleCatacombTomeStore({target: target[0]});
} else if (path[path.length - 1].toLowerCase() === 'chests' && path[0].toLowerCase() === 'wb') {
let target = $('app-game-world-boss-chests > div');
while(target.length < 1) {
await new Promise(resolve => setTimeout(resolve, 200))
target = $('app-game-world-boss-chests > div');
}
this.wbDropsObserver.observe(target[0], {
childList: true, subtree: false, attributes: false
});
} else if (path[path.length - 1].toLowerCase() === 'pets' && path[0].toLowerCase() === 'actions') {
let target = $('app-actions-pets > .scrollbar > div > .d-flex');
while(target.length < 1) {
await new Promise(resolve => setTimeout(resolve, 200))
target = $('app-actions-pets > .scrollbar > div > .d-flex');
}
// Insert stat ratios on the pets page
await this.insertPlayerStatRatios(target[0]);
} else if (path[path.length - 1].toLowerCase() === 'gems' && path[0].toLowerCase() === 'inventory') {
await this.insertFuseFrenzyButton();
}
}
async handlePersonalQuest(mutation) {
/**
* Handles a new update to the personal quests page. It loads in all
* the extra quest information, which differs depending on an active or
* non-active quest page view.
*/
//Filter out any unneeded mutations/updates to the page
if(mutation?.addedNodes?.length < 1 ||
mutation?.addedNodes?.[0]?.localName === 'mat-tooltip-component' ||
mutation?.addedNodes?.[0]?.nodeName === 'TH' ||
mutation?.addedNodes?.[0]?.nodeName === 'TD' ||
mutation?.addedNodes?.[0]?.nodeName === '#text' ||
mutation?.addedNodes?.[0]?.className === 'mat-ripple-element' ||
mutation?.addedNodes?.[0]?.id === 'questInfoRow') {
return;
}
//Modify the table used to hold quest information
const questTable = mutation.target.parentElement.tagName === 'TABLE' ? mutation.target.parentElement : mutation.target.querySelector('table');
if(questTable) {
let infoRow = null;
//Add end time column to table
this.addEndTimeColumn(questTable);
const tableBody = questTable.children[1];
//There are two states: active quest and no quest
if(tableBody.children.length > 2) {//No quest
//Get the info row that goes at the bottom
infoRow = await this.modifyQuestInfo(tableBody, false, false);
} else if(tableBody.children.length > 0) { //Active quest
//Update number of refreshes used, just in case
await this.updateRefreshes();
infoRow = await this.modifyQuestInfo(tableBody, false, true);
} else {
return;
}
//Add an extra row for extra quest info if there isn't one already
if(!document.getElementById('questInfoRow')) tableBody.appendChild(infoRow);
}
}
async handleVillageQuest(mutation) {
/**
* Handles a new update to the village quests page. It loads in all
* the extra quest information, which differs depending on an active or
* non-active quest page view.
*/
//Filter out unneeded mutations/updates to page
if(mutation?.addedNodes?.length < 1 ||
mutation?.addedNodes?.[0]?.nodeName === '#text' ||
mutation?.addedNodes?.[0]?.nodeName === 'TH' ||
mutation?.addedNodes?.[0]?.nodeName === 'TD' ||
mutation?.addedNodes?.[0]?.className === 'mat-ripple-element' ||
mutation?.addedNodes?.[0]?.id === 'questInfoRow') {
return;
}
const questTable = mutation.target.parentElement.tagName === 'TABLE' ? mutation.target.parentElement : mutation.target.querySelector('table');
if(questTable) {
await this.updateVillageRefreshes(); //Update for refreshes used
this.addEndTimeColumn(questTable);
//Add end time
const tableBody = questTable.children[1];
//Add end time elems to the end time column
if(tableBody.children.length > 2) { //Quest is not active
await this.modifyQuestInfo(tableBody, true, false);
} else { //Quest is active
await this.modifyQuestInfo(tableBody, true, true);
}
//Add info text at the bottom of quest table
const infoRow = document.createTextNode('End time is calculated assuming all members are active. The time is approximate and may not be accurate.'
+ `${this.quest.villageRefreshesUsed}/${this.quest.villageNumRefreshes} refreshes used.`);
infoRow.id = 'questExplanation';
if(questTable.parentElement.lastChild.id !== 'questExplanation') {
questTable.parentElement.appendChild(infoRow);
}
}
}
async handleCatacombPage(mutation) {
/**
* Handle an update on the catacomb page, and insert an end time into the page
* for any selected catacomb.
**/
if ( // skip unnecessary updates
mutation?.addedNodes?.[0]?.localName === 'mat-tooltip-component' ||
mutation?.addedNodes?.[0]?.className === 'mat-ripple-element' ||
mutation?.addedNodes?.[0]?.nodeName === '#text' ||
mutation?.addedNodes?.[0]?.id === 'catacombEndTime'
) {
return;
}
const mainView = document.querySelector('app-catacomb-main');
//Check if active or inactive view
if (mainView.firstChild.nodeName === '#comment') { // Active view
const parentElement = mainView.firstElementChild.firstChild.firstChild.firstChild;
const secondsLeft = parseNumber(parentElement.children[1].innerText);
// Use api data
const totalMobs = this.gameData.playerCatacombService.actionData.catacombStats.mobCount;
const mobsKilled = this.gameData.playerCatacombService.actionData.catacombStats.killCount;
// Create the end time ele to insert into
const endTimeEle = document.getElementById('catacombEndTime') ?? document.createElement('div');
endTimeEle.id = 'catacombEndTime';
endTimeEle.setAttribute('class', 'h5');
endTimeEle.innerText = `| End time: ${getCatacombEndTime(totalMobs - mobsKilled, this.catacomb.actionTimerSeconds, secondsLeft)}`;
parentElement.appendChild(endTimeEle);
} else { // Inactive view
const mobOverviewEle = mainView.firstChild.children[1].firstChild.firstChild;
const totalMobs = parseNumber(mobOverviewEle.firstChild.children[1].firstChild.children[11].children[1].innerText);
const cataTierSelectionEle = mobOverviewEle.children[1];
// Create the end time ele to insert into
const endTimeEle = document.getElementById('catacombEndTime') ?? document.createElement('div');
endTimeEle.id = 'catacombEndTime';
endTimeEle.innerText = `End time (local): ${getCatacombEndTime(totalMobs, this.catacomb.actionTimerSeconds)}`;
cataTierSelectionEle.appendChild(endTimeEle);
// Create tooltips for gold/hr and emblems/hr
const goldEle = mobOverviewEle.firstChild.children[1].firstChild.children[9].children[1];
const boostedGoldPerKill = parseNumber(goldEle.innerText);
const goldHr = boostedGoldPerKill / this.catacomb.actionTimerSeconds * 3600;
goldEle.parentElement.setAttribute('title', `${goldHr.toLocaleString(undefined, {maximumFractionDigits:2})}/Hr`);
const emblemsEle = mobOverviewEle.firstChild.children[1].firstChild.children[10].children[1];
const emblemsHr = parseNumber(emblemsEle.innerText) / totalMobs / this.catacomb.actionTimerSeconds * 3600;
emblemsEle.parentElement.setAttribute('title', `${emblemsHr.toLocaleString(undefined, {maximumFractionDigits:2})}/Hr`);
// Highlight start button if tomes are equipped
const goldPerKillEle = mutation.target.parentElement.parentElement?.previousSibling?.children?.[9]?.firstElementChild;
if (!goldPerKillEle) return; // Early return if element cannot be found, since mutations can come from anything
const baseGoldPerKill = parseNumber(goldPerKillEle.innerText);
const startCataButton = mobOverviewEle.nextSibling.firstChild;
if (this.catacomb.tomesAreEquipped && baseGoldPerKill < this.tomeSettings.goldKillTomesEquippedAmount) {
startCataButton.style.boxShadow = '0px 0px 12px 7px red';
startCataButton.style.color = 'red';
} else {
startCataButton.style.boxShadow = 'none';
startCataButton.style.color = '';
}
}
}
async handleCatacombTomeStore(mutation) {
/**
* Add highlights around tomes with good boosts and obscures bad tomes
* Credit to Ender for code collaboration and fading out tomes
*
**/
if ( // skip unnecessary updates
mutation?.addedNodes?.[0]?.localName === 'mat-tooltip-component' ||
mutation?.addedNodes?.[0]?.className === 'mat-ripple-element' ||
mutation?.addedNodes?.[0]?.nodeName === '#text' ||
mutation?.addedNodes?.[0]?.id === 'highlight'
) {
return;
}
// Get store element and tome store data
const tomeElements = $('app-catacomb-tome-store > div > div > div.base-scrollbar > div > div');
let tomes = this.gameData.playerCatacombService?.tomeStore;
while (this.gameData.playerCatacombService === undefined || tomes === undefined) {
await new Promise(resolve => setTimeout(resolve, 200))
tomes = this.gameData.playerCatacombService?.tomeStore;
}
// Put an id on the first tome of the store to mark it as "processed"
tomeElements[0].id = 'highlight';
// Get the refresh button for disabling it
const refreshButton = $('app-catacomb-tome-store > div > div > div.my-auto > div > button')[0];
refreshButton.style.touchAction = 'manipulation'; // Disable double tap zoom for mobile when tapping the button
// For each tome (loop by index), check if tome has good modifiers.
for (let i = 0; i < tomes.length; i++) {
const tomeMods = tomes[i];
const tomeElement = tomeElements[i].firstChild;
let shouldFadeTome = true;
let highlightIncome = false;
if (this.tomeSettings?.useWeightSettings) {
// Create row after tome mods to display the power per space values
const displayEle = tomeElement.querySelector(`#perspacedisplay-${tomeMods.id}`) ?? document.createElement('div');
displayEle.id = `perspacedisplay-${tomeMods.id}`;
displayEle.className = 'd-flex justify-content-between ng-star-inserted';
tomeElement.appendChild(displayEle, tomeElement.nextSibling.firstChild);
if (this.checkTomeIncomePower(tomeMods, displayEle)) { // Displays income power as well
shouldFadeTome = false;
highlightIncome = true;
}
if (this.checkTomeWBPower(tomeMods, displayEle)) { // Displays wb power as well
shouldFadeTome = false;
}
} else {
if (this.checkTomeIncomeMeetsThresholds(tomeMods, tomeElement)) {
shouldFadeTome = false;
highlightIncome = true;
}
if (this.checkTomeWBMeetsThresholds(tomeMods, tomeElement)) {
shouldFadeTome = false;
}
}
// Fade out tomes that didn't meet requirements
if (shouldFadeTome) {
tomeElement.style.color = "rgba(255, 255, 255, 0.4)";
[...tomeElement.children].forEach((child) => {
child.style.opacity = "0.4";
});
} else {
if (this.tomeSettings.disableRefreshOnHighlight) {
// Briefly disable the refresh button
refreshButton.disabled = true;
refreshButton.className = 'mat-focus-indicator mat-stroked-button mat-button-base';
document.querySelector('#stopScrollDiv').focus({preventScroll: true}); // Prevent spacebar from scrolling down
setTimeout((button) => {
button.disabled = false;
button.className = 'mat-focus-indicator mat-raised-button mat-button-base';
button.focus({preventScroll: true});
}, 1600, refreshButton);
}
if (highlightIncome) {
tomeElement.parentElement.style.boxShadow = '0 0 30px 15px #48abe0';
} else {
tomeElement.parentElement.style.boxShadow = '0 0 30px 15px green';
}
}
// Hide modifiers on tomes according to the settings
const modifierOrder = [
'tomeName', 'spaceReq', 'addedMobs', 'reward', 'mobDebuff', 'character', 'waterResistance',
'thunderResistance', 'fireResistance', 'meleeResistance', 'rangedResistance', 'elementalConversion',
'fortifyReduction'
];
for (let i = 2; i < tomeElement.children.length; i++) {
const modifierEle = tomeElement.children[i];
if (this.tomeSettings.hideMods[modifierOrder[i]]) {
modifierEle.style.setProperty('display', 'none', 'important');
} else {
modifierEle.style.display = '';
}
}
}
}
checkTomeIncomePower(tomeMods, powerDisplayEle) {
/**
* Returns true if the tomeMods are weighted and meet the thresholds according to the tome settings for income.
* It also displays the income power on the tome using the powerDisplayEle elememt
*/
let incomePerSpace = 0;
incomePerSpace += (tomeMods.multi_mob ?? 0) / 100 * (this.tomeSettings.weights.multiMob ?? 0);
incomePerSpace += (tomeMods.character_multiplier ?? 0) / 100 * (this.tomeSettings.weights.character ?? 0);
incomePerSpace += (tomeMods.speed ?? 0) / 100 * (this.tomeSettings.weights.actionSpeed ?? 0);
incomePerSpace += (tomeMods.skip ?? 0) / 100 * (this.tomeSettings.weights.mobSkip ?? 0);
incomePerSpace += (tomeMods.lifesteal ?? 0) / 100 * (this.tomeSettings.weights.lifesteal ?? 0);
incomePerSpace += (tomeMods.reward_multiplier ?? 0) / 100 * (this.tomeSettings.weights.reward ?? 0);
incomePerSpace += (tomeMods.mob_multiplier ?? 0) / 100 * (this.tomeSettings.weights.mobDebuff ?? 0);
incomePerSpace /= tomeMods.space_requirement;
// Display the income power per space in the powerDisplayEle
const incomePerSpaceEle = powerDisplayEle.querySelector(`#incomeperspace-${tomeMods.id}`) ?? document.createElement('div');
incomePerSpaceEle.id = `incomeperspace-${tomeMods.id}`;
incomePerSpaceEle.innerText = `Income: ${Math.round(incomePerSpace).toLocaleString()}`;
powerDisplayEle.appendChild(incomePerSpaceEle);
// Check
if (incomePerSpace >= this.tomeSettings.weights.incomePerSpaceThreshold) {
incomePerSpaceEle.style.color = 'gold';
return true;
}
return false;
}
checkTomeWBPower(tomeMods, powerDisplayEle) {
/**
* Returns true if the tomeMods are weighted and meet the thresholds according to the tome settings for world boss.
* It also displays the WB power on the tome using the powerDisplayEle elememt
*/
let wbPowerPerSpace = (tomeMods.character_multiplier ?? 0) / 100.0 * (this.tomeSettings.weights.wbCharacter ?? 0);
wbPowerPerSpace += (tomeMods.elemental_conversion ?? 0) / 100.0 * (this.tomeSettings.weights.wbElementalConv ?? 0);
wbPowerPerSpace /= tomeMods.space_requirement;
// Display the WB power per space in the powerDisplayEle
const wbPowerPerSpaceEle = powerDisplayEle.querySelector(`#wbpowerperspace-${tomeMods.id}`) ?? document.createElement('div');
wbPowerPerSpaceEle.id = `wbpowerperspace-${tomeMods.id}`;
wbPowerPerSpaceEle.innerText = `WB: ${Math.round(wbPowerPerSpace).toLocaleString()}`;
powerDisplayEle.appendChild(wbPowerPerSpaceEle);
if (wbPowerPerSpace >= this.tomeSettings.weights.wbPowerPerSpaceThreshold) {
wbPowerPerSpaceEle.style.color = 'forestgreen';
return true;
}
return false;
}
checkTomeIncomeMeetsThresholds(tomeMods, tomeElement) {
/**
* Returns true if given tomes meets thresholds accored to the stored tome settings for income
* Also highlight the tomeElement if threshold is met
*/
let sumGoodRolls = 0;
// Check each important mods: reward, character, mob, lifesteal, multi mob, action speed, mob skip
// For each mod, if it meets the settings highlight the mod and increment the number of rolls
if (
this.tomeSettings.thresholds.reward > 0
&& tomeMods.reward_multiplier >= this.tomeSettings.thresholds.reward
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.reward
) {
const isDouble = tomeMods.reward_multiplier >= this.tomeSettings.thresholds.reward * 2;
tomeElement.children[3].style.border = `${isDouble ? 'thick' : '2px'} solid`;
tomeElement.children[3].style.borderColor = tomeElement.children[3].firstChild.style.color ?? 'gold';
sumGoodRolls += Math.floor(tomeMods.reward_multiplier / this.tomeSettings.thresholds.reward);
}
if (
this.tomeSettings.thresholds.mobDebuff > 0
&& tomeMods.mob_multiplier >= this.tomeSettings.thresholds.mobDebuff
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.mobDebuff
) {
const isDouble = tomeMods.mob_multiplier >= this.tomeSettings.thresholds.mobDebuff * 2;
tomeElement.children[4].style.border = `${isDouble ? 'thick' : '2px'} solid`;
tomeElement.children[4].style.borderColor = tomeElement.children[4].firstChild.style.color ?? 'white';
sumGoodRolls += Math.floor(tomeMods.mob_multiplier / this.tomeSettings.thresholds.mobDebuff);
}
if (
this.tomeSettings.thresholds.character > 0
&& tomeMods.character_multiplier >= this.tomeSettings.thresholds.character
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.character
) {
const isDouble = tomeMods.character_multiplier >= this.tomeSettings.thresholds.character * 2;
tomeElement.children[5].style.border = `${isDouble ? 'thick' : '2px'} solid`;
tomeElement.children[5].style.borderColor = tomeElement.children[5].firstChild.style.color ?? 'white';
sumGoodRolls += Math.floor(tomeMods.character_multiplier / this.tomeSettings.thresholds.character);
}
if (
this.tomeSettings.thresholds.lifesteal > 0
&& tomeMods.lifesteal >= this.tomeSettings.thresholds.lifesteal
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.rare
) {
sumGoodRolls += Math.floor(tomeMods.lifesteal / this.tomeSettings.thresholds.lifesteal);
}
if (
this.tomeSettings.thresholds.multiMob > 0
&& tomeMods.multi_mob >= this.tomeSettings.thresholds.multiMob
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.rare
) {
sumGoodRolls += Math.floor(tomeMods.multi_mob / this.tomeSettings.thresholds.multiMob);
}
if (
this.tomeSettings.thresholds.actionSpeed > 0
&& tomeMods.speed >= this.tomeSettings.thresholds.actionSpeed
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.legendary
) {
sumGoodRolls += Math.floor(tomeMods.speed / this.tomeSettings.thresholds.actionSpeed);
}
if (
this.tomeSettings.thresholds.mobSkip > 0
&& tomeMods.skip >= this.tomeSettings.thresholds.mobSkip
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.legendary
) {
sumGoodRolls += Math.floor(tomeMods.skip / this.tomeSettings.thresholds.mobSkip);
}
return sumGoodRolls >= this.tomeSettings.thresholds.numGoodRolls;
}
checkTomeWBMeetsThresholds(tomeMods, tomeElement) {
/**
* Returns true if given tomes meets thresholds accored to the stored tome settings for world boss
* Also highlight the tomeElement if threshold is met
*/
let sumRolls = 0;
if (
this.tomeSettings.thresholds.elementalConv > 0
&& tomeMods.elemental_conversion >= this.tomeSettings.thresholds.elementalConv
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.wb
) {
const isDoubleElemental = tomeMods.elemental_conversion >= this.tomeSettings.thresholds.elementalConv * 2;
sumRolls += Math.floor(tomeMods.elemental_conversion / this.tomeSettings.thresholds.elementalConv);
tomeElement.children[11].style.border = `${isDoubleElemental ? 'thick' : '1px'} solid`;
tomeElement.children[11].style.borderColor = 'forestgreen';
}
if (
this.tomeSettings.thresholds.characterWb > 0
&& tomeMods.character_multiplier >= this.tomeSettings.thresholds.characterWb
&& tomeMods.space_requirement <= this.tomeSettings.spaceThresholds.wb
) {
const isDoubleCharacter = tomeMods.character_multiplier >= this.tomeSettings.thresholds.characterWb * 2;
sumRolls += Math.floor(tomeMods.character_multiplier / this.tomeSettings.thresholds.characterWb);
tomeElement.children[5].style.border = `${isDoubleCharacter ? 'thick' : '1px'} solid`;
tomeElement.children[5].style.borderColor = 'forestgreen';
}
return sumRolls >= this.tomeSettings.thresholds.numGoodRollsWb;
}
async handleWbChestOpening(mutation) {
/**
* Highlight drops that are desirable
* - Gems over the kd level
* - Descriptions with max depth 31+
* - Equipment with depth 31+
**/
// Check if first time opening chests on page
if (mutation?.addedNodes?.[0]?.innerText && mutation.addedNodes[0].innerText.startsWith('After')) {
// Change observer to listen to subsequent chest openings
let target = document.querySelector('app-game-world-boss-chest-drops');
this.wbDropsObserver.disconnect();
this.wbDropsObserver.observe(target, {
childList: true, subtree: false, attributes: false
});
}
// Get list of drops
const dropsCategories = document.querySelector('app-game-world-boss-chest-drops').children;
for (const category of dropsCategories) {
const text = category.innerText.split(' ');
const dropType = text[text.length - 1].toLowerCase();
if (dropType === 'gem' || dropType === 'description' || dropType === 'item') {
// They are grouped together in an inner list, so extract the inner list
const dropList = category.firstElementChild.children;
for (const drop of dropList) {
const text = drop.innerText.split(' ');
// Additional filters
if (dropType === 'gem' && parseNumber(text[1]) < this.kdExploLevel) {
// Gem has to be higher level than kd exploration level
continue;
} else if (dropType === 'description' && parseNumber(text[1].split('-')[1]) <= 30) {
// Description has to be max depth 31+
continue;
} else if (dropType === 'item' && parseNumber(text[1]) < 31) {
// Fighter item must be depth 31+
continue;
}
// Highlight the element
drop.style.backgroundColor = 'darkblue';
}
}
}
}
stopObserver(pathname) {
const stop = {
'actions,quests': () => this.personalQuestObserver.disconnect(),
'village,quests': () => this.villageQuestObserver.disconnect(),
'catacombs,catacomb': () => this.catacombObserver.disconnect(),
'catacombs,tome_store': () => this.tomeObserver.disconnect(),
'wb,chests': () => this.wbDropsObserver.disconnect(),
'portal': () => { setTimeout(this.initPlayerData.bind(this), 2000)},
'inventory,gems': () => { this.gems = [] },
}
if(stop[pathname]) {
stop[pathname]();
}
}
getStatReward() {
/**
* Returns the possible max and min values for stat quests
*/
return {
max: Math.round((this.quest.questsCompleted/300+this.quest.baseStat+22.75)*(1+this.quest.villageBold*2/100)*1.09),
min: Math.round((this.quest.questsCompleted/300+this.quest.baseStat+8.5)*(1+this.quest.villageBold*2/100)*1.09),
}
}
async getQuestInfoElem(actionsNeeded) {
/**
* Returns the info row used for active personal quest page
*/
const partyActions = await this.getPartyActions();
let row = document.createElement('tr');
const date = new Date();
//actionsNeeded * 6000 = actions * 6 sec per action * 1000 milliseconds
const finishPartyTime = new Date(date.getTime() + (actionsNeeded + partyActions) * 6000).toLocaleTimeString('en-GB').match(/\d\d:\d\d/)[0];
const info = ['',`${this.quest.refreshesUsed}/${this.quest.numRefreshes} refreshes used`, '',
actionsNeeded >= 0 ? `End time (local time) with ${partyActions} party actions: ${finishPartyTime}`: ''];
let htmlInfo = '';
for (let text of info) {
htmlInfo += `<td>${text}</td>`
}
row.innerHTML = htmlInfo;
row.id = 'questInfoRow';
return row;
}
getTimeElem(actionsNeeded, className, isVillage=true) {
/**
* Returns an element used to describe the end time for each quest, used for
* the end time column. It has styled CSS through the className, and the
* time calculation differs for village vs personal. If there are an
* invalid number of actionsNeeded, the time is N/A.
*/
const cell = document.createElement('td');
if(actionsNeeded > 0) {
const date = new Date();
const numPeople = isVillage ? this.quest.villageSize : 1;
//actionsNeeded * 6 sec per action * 1000 milliseconds / numPeople
const finishTime = new Date(date.getTime() + actionsNeeded * 6000 / numPeople).toLocaleTimeString('en-GB').match(/\d\d:\d\d/)[0];
cell.innerText = finishTime;
} else {
cell.innerText = 'N/A';
}
cell.setAttribute('class', className);
return cell;
}
getQuestRatioInfo() {
//Return info row used for inactive personal quests
let row = document.createElement('tr');
const stat = this.getStatReward();
const avg = (stat.max/this.quest.minActions + stat.min/this.quest.maxActions) / 2;
const info = ['Possible stat ratios, considering quests completed & village bold:',
`Worst ratio: ${(stat.min/this.quest.maxActions).toFixed(3)}`,
`Avg ratio: ${(avg).toFixed(3)}`,
`Best Ratio: ${(stat.max/this.quest.minActions).toFixed(3)}`,
''
];
let htmlInfo = '';
for (let text of info) {
htmlInfo += `<td>${text}</td>`
}
row.innerHTML = htmlInfo;
row.setAttribute('class', 'mat-row cdk-row ng-star-inserted');
row.id = 'questInfoRow';
return row;
}
addEndTimeColumn(tableElem) {
//Given a table element, add a new column for end time and add times to each row
if(tableElem === undefined) return;
//Add header title for the column
if(tableElem?.firstChild?.firstChild?.nodeType !== 8 &&
(tableElem.firstChild.firstChild.children?.[3]?.id !== 'endTimeHeader' //Inactive vs active quest
&& tableElem.firstChild.firstChild.children?.[4]?.id !== 'endTimeHeader')) {
const header = document.createElement('th');
header.innerText = 'End Time (local time)';
header.id = 'endTimeHeader';
header.setAttribute('class', tableElem.firstChild.firstChild?.firstChild?.className ?? 'mat-header-cell cdk-header-cell cdk-column-current mat-column-current ng-star-inserted');
tableElem.firstChild.firstChild.appendChild(header);
}
}
async modifyQuestInfo(tableBody, isVillage, isActiveQuest) {
/* Returns info row because I suck at structure
** Also inserts the end time for each quest
*/
//First, determine if quest is active
if(isActiveQuest && tableBody.children[0]) {
//If it is, parse the text directly to get the end time
const row = tableBody.children[0];
const objectiveElemText = row?.children[1].innerText.split(' ');
let timeElem;
if(objectiveElemText[3].toLowerCase() === 'actions' || objectiveElemText[3].toLowerCase() === 'survived') {
const actionsDone = parseNumber(objectiveElemText[0]);
const objective = parseNumber(objectiveElemText[2]);
const reward = row.children[2].innerText.split(' ');
let actionsNeeded = -1;
//Special case: Party action quest (because it has 7 sec timer)
if(row.children[2].innerText.split(' ')[1].toLowerCase() === 'party') {
actionsNeeded = (objective - actionsDone) * 7 / 6;
} else {
actionsNeeded = objective - actionsDone;
}
timeElem = this.getTimeElem(actionsNeeded, row.firstChild.className, isVillage);
row.appendChild(timeElem);
//Add ratios
if(reward[1].toLowerCase() === 'gold') {
const ratio = Math.round(parseInt(reward[0]) / objective * 600).toLocaleString();
row.children[2].innerText = `${row.children[2].innerText} (${ratio} gold/hr)`;
} else if(!isVillage) {
const ratio = (parseInt(reward[0]) / objective).toFixed(3);
row.children[2].innerText = `${row.children[2].innerText} (${ratio})`;
}
return await this.getQuestInfoElem(actionsNeeded);
} else if(objectiveElemText[3].toLowerCase() === 'base') { //Special case: Exp reward quest
const goldCollected = parseNumber(objectiveElemText[0]);
const objective = parseNumber(objectiveElemText[2]);
const currentMonster = this.gameData.playerActionService.selectedMonster;
const baseGoldPerAction = 8 + 2 * currentMonster;
const actionsNeeded = Math.ceil((objective - goldCollected) / baseGoldPerAction);
// Insert end time
timeElem = this.getTimeElem(actionsNeeded, row.firstChild.className, isVillage);
row.appendChild(timeElem);
//Add ratio
const expNeeded = this.gameData.playerLevelsService.battling.exp.needed;
const reward = parseNumber(row.children[2].innerText.split(' ')[0].replace(/,/g, '')) / 100;
const ratio = Math.round((expNeeded * reward) / (objective / baseGoldPerAction)).toLocaleString();
row.children[2].innerText = `${row.children[2].innerText} (${ratio} exp/action)`;
// Replace exp requirement with action requirement
const actionsDone = Math.floor(goldCollected / baseGoldPerAction).toLocaleString();
const actionsLeft = Math.ceil(objective / baseGoldPerAction).toLocaleString();
row.children[1].innerText = `${actionsDone} / ${actionsLeft} actions (does not update)`;
return await this.getQuestInfoElem(actionsNeeded);
} else {
timeElem = this.getTimeElem(-1, row.firstChild.className, isVillage);
row.appendChild(timeElem);
return await this.getQuestInfoElem(-1);
}
} else if(isVillage && tableBody.children[0]) {
const refreshButton = $('app-village-quests > div > div > div.mt-3 > button')[0];
refreshButton.style.touchAction = 'manipulation'; // Disable double tap zoom for mobile when tapping the button
//Get village quests
for(let i = 0; i < tableBody.children.length; i++) {
let row = tableBody.children[i];
const objectiveText = row.children[1].innerText.split(' ');
let timeElem = null;
let meetsHighlightReq = false;
if(objectiveText[1] === 'actions') {
//Add border if there's a str point reward or it meets the minResAction threshold
const rewardText = row.children[2].innerText.split(' ');
const reward = rewardText[1];
if(reward === 'strength' && parseNumber(objectiveText[0]) <= this.villageSettings.strActions) {
meetsHighlightReq = true;
} else if (parseNumber(rewardText[0]) / parseNumber(objectiveText[0]) >= this.villageSettings.resActionRatio) {
// res reward ratio meets threshold setting
meetsHighlightReq = true;
}
if (meetsHighlightReq) {
row.children[2].style.border = 'inset';
if (this.tomeSettings.disableRefreshOnHighlight) {
refreshButton.disabled = true;
refreshButton.className = 'mat-focus-indicator mr-2 mat-stroked-button mat-button-base';
document.querySelector('#stopScrollDiv').focus({preventScroll: true}); // Prevent spacebar from scrolling down
setTimeout((button) => {
button.disabled = false;
button.className = 'mat-focus-indicator mr-2 mat-raised-button mat-button-base';
button.focus({preventScroll: true});
}, 1200, refreshButton);
}
}
//Insert end time
const objective = parseNumber(objectiveText[0]);
timeElem = this.getTimeElem(objective, row.firstChild.className, true);
} else {
timeElem = this.getTimeElem(-1, row.firstChild.className, true);
}
row.appendChild(timeElem);
}
return;
} else if(tableBody.children[0]) { //personal not active quests
const availableQuests = this.gameData.playerQuestService.questArray;
//Go through each quest and update row accordingly
for(let i = 0; i < availableQuests.length; i++) {
const row = tableBody.children[i];
let actionsNeeded = -1;
if(availableQuests[i].type === 'swordsman' || availableQuests[i].type === 'tax' ||
availableQuests[i].type === 'gems' || availableQuests[i].type === 'spell') {
//Above are the quests that require actions to be done
actionsNeeded = parseNumber(availableQuests[i].objective.split(' ')[0]);
} else if(availableQuests[i].type === 'treasure') {
actionsNeeded = parseNumber(availableQuests[i].objective.split(' ')[0]);
//Insert a gold ratio
const reward = parseNumber(availableQuests[i].reward.split(' ')[0]);
const ratio = Math.round(reward / actionsNeeded * 600).toLocaleString();
row.children[1].innerText = `${row.children[1].innerText} (${ratio} gold/hr)`;
} else if(availableQuests[i].type === 'slow') {
//Convert 7 second actions to 6 second actions
actionsNeeded = parseNumber(availableQuests[i].objective.split(' ')[0]) * 7 / 6;
} else if(availableQuests[i].type === 'friend') { //Base gold objective
const goldObjective = parseNumber(availableQuests[i].objective.split(' ')[0]);
const currentMonster = this.gameData.playerActionService.selectedMonster;
actionsNeeded = Math.ceil(goldObjective / (8 + 2 * currentMonster));
//Insert a exp ratio
const reward = parseNumber(row.children[1].innerText.split(' ')[0]);
const ratio = Math.round(reward / actionsNeeded).toLocaleString();
row.children[1].innerText = `${row.children[1].innerText} (${ratio} exp/action)`;
// Convert gold requirement to action requirement
row.children[0].innerText = `${actionsNeeded.toLocaleString()} actions`;
}
if(row.id !== 'questInfoRow'){
const timeElem = this.getTimeElem(actionsNeeded, row.firstChild.className, false);
row.appendChild(timeElem);
}
}
return this.getQuestRatioInfo(); //The bottom row that contains extra info
}
}
async insertVillageSettingsElem() {
/**
* Inserts a custom settings box into the village settings page
*/
//Get settings page contents
let settingsOverview = document.querySelector('app-village-settings');
while(!settingsOverview) {
await new Promise(resolve => setTimeout(resolve, 50));
settingsOverview = document.querySelector('app-village-settings');
}
//Clone a copy of the armory settings to match the css style
const questSettings = settingsOverview.firstChild.children[1].cloneNode(true);
questSettings.style.width = '35%';
questSettings.style.maxWidth = '';
//Modify to our liking
questSettings.firstChild.children[3].remove();
questSettings.firstChild.children[2].remove();
questSettings.firstChild.firstChild.innerText = 'QuesBS Highlight Quest';
questSettings.firstChild.children[1].firstChild.innerText = 'Max actions for strength point';
questSettings.firstChild.children[1].children[1].id = 'actionsLimitSetting';
questSettings.firstChild.children[1].children[1].style.width = '50%';
questSettings.firstChild.children[1].children[1].firstChild.value = this.villageSettings.strActions;
questSettings.firstChild.children[1].children[1].firstChild.style.width = '6em';
// Clone for extra rows
const resActionSetting = questSettings.firstChild.children[1].cloneNode(true);
resActionSetting.firstChild.innerText = 'Min ratio for res/action quests';
resActionSetting.children[1].id = 'minResActionSetting';
resActionSetting.children[1].firstChild.value = this.villageSettings.resActionRatio ?? 999999999999;
resActionSetting.children[1].firstChild.style.width = '9em';
const taxObjectiveSetting = questSettings.firstChild.children[1].cloneNode(true);
taxObjectiveSetting.firstChild.innerText = 'Max tax objective % (0.5~1.25)';
taxObjectiveSetting.children[1].id = 'taxObjectiveSetting';
taxObjectiveSetting.children[1].firstChild.value = this.villageSettings.taxObjectivePercent ?? 0;
// Insert
const saveButton = questSettings.firstChild.children[2];
saveButton.disabled = false;
saveButton.className = 'mat-focus-indicator mat-raised-button mat-button-base'; // Make it look enabled in case it's disabled
questSettings.firstChild.insertBefore(resActionSetting, saveButton);
questSettings.firstChild.insertBefore(taxObjectiveSetting, saveButton);
saveButton.firstChild.firstChild.innerText = 'Save QuesBS Quests';
//Add a save function for button
saveButton.firstChild.onclick = () => {
const newActions = parseNumber(document.getElementById('actionsLimitSetting').firstChild.value);
const newResActions = parseNumber(document.getElementById('minResActionSetting').firstChild.value);
const newTaxObjectivePercent = parseNumber(document.getElementById('taxObjectiveSetting').firstChild.value);
//Data validation
if(isNaN(newActions) || isNaN(newTaxObjectivePercent) || isNaN(newResActions)) {
this.gameData.snackbarService.openSnackbar('Error: Value should be a number'); //feedback popup
} else if (newTaxObjectivePercent < 0 || newTaxObjectivePercent > 1.25) {
this.gameData.snackbarService.openSnackbar('Error: Tax % should be between 0 and 1.25');
} else {
this.villageSettings.strActions = newActions;
this.villageSettings.taxObjectivePercent = newTaxObjectivePercent;
this.villageSettings.resActionRatio = newResActions;
localStorage.setItem(`${this.playerId}:QuesBS_villageSettings`, JSON.stringify(this.villageSettings));
this.gameData.snackbarService.openSnackbar('Settings saved successfully'); //feedback popup
}
}
settingsOverview.appendChild(questSettings);
}
async modifyTomeStorePage() {
/**
* Inserts a custom popup menu for tome settings
*/
//Get store page contents
let tomeStoreOverview = document.querySelector('app-catacomb-tome-store');
while(!tomeStoreOverview) {
await new Promise(resolve => setTimeout(resolve, 50));
tomeStoreOverview = document.querySelector('app-catacomb-tome-store');
}
// Create settings menu
const settings = document.createElement('div');
settings.id = 'highlightTomeSettings';
settings.style.margin = '1rem';
settings.style.position = 'relative';
settings.innerHTML = GM_getResourceText('settingsMenu');
const openTomeSettingsbutton = document.createElement('button');
openTomeSettingsbutton.id = 'openTomeSettingsButton';
openTomeSettingsbutton.className = 'mat-focus-indicator mat-raised-button mat-button-base';
openTomeSettingsbutton.innerText = 'QuesBS Tome Settings';
settings.insertBefore(openTomeSettingsbutton, settings.childNodes[0]);
const topStoreBar = tomeStoreOverview.firstChild.firstChild;
topStoreBar.insertBefore(settings, topStoreBar.firstChild);
// Display correct settings
const settingsContainer = settings.childNodes[1];
if (this.tomeSettings?.useWeightSettings) {
settingsContainer.querySelector('#tomeWeightSettingsBlock').style.display = 'block';
settingsContainer.querySelector('#thresholdSettingsBlock').style.display = 'none';
}
// Fill in input values
const d = 99900; // Default value
settingsContainer.querySelector('#rewardHighlightSetting').value = ((this.tomeSettings.thresholds.reward ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#mobHighlightSetting').value = ((this.tomeSettings.thresholds.mobDebuff ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#characterHighlightSetting').value = ((this.tomeSettings.thresholds.character ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#characterWbHighlightSetting').value = ((this.tomeSettings.thresholds.characterWb ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#elementalConvHighlightSetting').value = ((this.tomeSettings.thresholds.elementalConv ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#multiMobHighlightSetting').value = ((this.tomeSettings.thresholds.multiMob ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#lifestealHighlightSetting').value = ((this.tomeSettings.thresholds.lifesteal ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#actionSpeedHighlightSetting').value = ((this.tomeSettings.thresholds.actionSpeed ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#mobSkipHighlightSetting').value = ((this.tomeSettings.thresholds.mobSkip ?? d) / 100).toFixed(2);
settingsContainer.querySelector('#rewardSpaceSetting').value = this.tomeSettings.spaceThresholds.reward ?? 6;
settingsContainer.querySelector('#mobSpaceSetting').value = this.tomeSettings.spaceThresholds.mobDebuff ?? 6;
settingsContainer.querySelector('#characterSpaceSetting').value = this.tomeSettings.spaceThresholds.character ?? 6;
settingsContainer.querySelector('#wbSpaceSetting').value = this.tomeSettings.spaceThresholds.wb ?? 9;
settingsContainer.querySelector('#rareSpaceSetting').value = this.tomeSettings.spaceThresholds.rare ?? 9;
settingsContainer.querySelector('#legendarySpaceSetting').value = this.tomeSettings.spaceThresholds.legendary ?? 9;
settingsContainer.querySelector('#numGoodRolls').value = this.tomeSettings.thresholds.numGoodRolls ?? 1;
settingsContainer.querySelector('#numGoodRollsWb').value = this.tomeSettings.thresholds.numGoodRollsWb ?? 2;
settingsContainer.querySelector('#actionSpeedWeight').value = this.tomeSettings.weights.actionSpeed ?? 0;
settingsContainer.querySelector('#mobSkipWeight').value = this.tomeSettings.weights.mobSkip ?? 0;
settingsContainer.querySelector('#multiMobWeight').value = this.tomeSettings.weights.multiMob ?? 0;
settingsContainer.querySelector('#lifestealWeight').value = this.tomeSettings.weights.lifesteal ?? 0;
settingsContainer.querySelector('#rewardWeight').value = this.tomeSettings.weights.reward ?? 0;
settingsContainer.querySelector('#mobDebuffWeight').value = this.tomeSettings.weights.mobDebuff ?? 0;
settingsContainer.querySelector('#characterWeight').value = this.tomeSettings.weights.character ?? 0;
settingsContainer.querySelector('#incomePerSpaceThreshold').value = this.tomeSettings.weights.incomePerSpaceThreshold ?? 0;
settingsContainer.querySelector('#wbCharacterWeight').value = this.tomeSettings.weights.wbCharacter ?? 0;
settingsContainer.querySelector('#wbElementalConvWeight').value = this.tomeSettings.weights.wbElementalConv ?? 0;
settingsContainer.querySelector('#wbPowerPerSpaceThreshold').value = this.tomeSettings.weights.wbPowerPerSpaceThreshold ?? 0;
settingsContainer.querySelector('#hideAddedMobs').checked = this.tomeSettings.hideMods.addedMobs ?? false;
settingsContainer.querySelector('#hideReward').checked = this.tomeSettings.hideMods.reward ?? false;
settingsContainer.querySelector('#hideMobDebuff').checked = this.tomeSettings.hideMods.mobDebuff ?? false;
settingsContainer.querySelector('#hideCharacter').checked = this.tomeSettings.hideMods.character ?? false;
settingsContainer.querySelector('#hideWaterResistance').checked = this.tomeSettings.hideMods.waterResistance ?? false;
settingsContainer.querySelector('#hideThunderResistance').checked = this.tomeSettings.hideMods.thunderResistance ?? false;
settingsContainer.querySelector('#hideFireResistance').checked = this.tomeSettings.hideMods.fireResistance ?? false;
settingsContainer.querySelector('#hideMeleeResistance').checked = this.tomeSettings.hideMods.meleeResistance ?? false;
settingsContainer.querySelector('#hideRangedResistance').checked = this.tomeSettings.hideMods.rangedResistance ?? false;
settingsContainer.querySelector('#hideElementalConversion').checked = this.tomeSettings.hideMods.elementalConversion ?? false;
settingsContainer.querySelector('#hideFortifyReduction').checked = this.tomeSettings.hideMods.fortifyReduction ?? false;
settingsContainer.querySelector('#goldPerKillForTomesEquipped').value = this.tomeSettings.goldKillTomesEquippedAmount ?? 0;
settingsContainer.querySelector('#disableRefreshOnHighlight').checked = this.tomeSettings.disableRefreshOnHighlight ?? true;
if (this.tomeSettings.useWeightSettings) {
settingsContainer.querySelector('#toggleWeightSettings').className = 'mat-focus-indicator mat-stroked-button mat-button-base';
} else {
settingsContainer.querySelector('#toggleThresholdSettings').className = 'mat-focus-indicator mat-stroked-button mat-button-base';
}
// Set up buttons
openTomeSettingsbutton.onclick = () => { // Toggle open and close menu
const container = document.querySelector('#tomeSettingsContainer');
if (container.style.display === 'none') {
container.style.display = 'inline-block';
} else {
container.style.display = 'none';
}
};
const toggleWeightSettings = (yes) => {
const thresholdSettingsBlock = settingsContainer.querySelector('#thresholdSettingsBlock');
const weightSettingsBlock = settingsContainer.querySelector('#tomeWeightSettingsBlock');
const toggleThresholdButton = settingsContainer.querySelector('#toggleThresholdSettings');
const toggleWeightButton = settingsContainer.querySelector('#toggleWeightSettings');
if(yes) {
weightSettingsBlock.style.display = 'block';
thresholdSettingsBlock.style.display = 'none';
this.tomeSettings.useWeightSettings = true;
toggleThresholdButton.className = 'mat-focus-indicator mat-raised-button mat-button-base';
toggleWeightButton.className = 'mat-focus-indicator mat-stroked-button mat-button-base';
} else {
weightSettingsBlock.style.display = 'none';
thresholdSettingsBlock.style.display = 'block';
this.tomeSettings.useWeightSettings = false;
toggleThresholdButton.className = 'mat-focus-indicator mat-stroked-button mat-button-base';
toggleWeightButton.className = 'mat-focus-indicator mat-raised-button mat-button-base';
}
}
settingsContainer.querySelector('#toggleThresholdSettings').onclick = () => toggleWeightSettings(false);
settingsContainer.querySelector('#toggleWeightSettings').onclick = () => toggleWeightSettings(true);
settingsContainer.querySelector('#tomeSettingsSaveButton').onclick = () => {
// Get all of the values
const container = document.querySelector('#tomeSettingsContainer');
const tomeSettings = {
thresholds: {
reward: container.querySelector('#rewardHighlightSetting').valueAsNumber * 100,
mobDebuff: container.querySelector('#mobHighlightSetting').valueAsNumber * 100,
character: container.querySelector('#characterHighlightSetting').valueAsNumber * 100,
characterWb: container.querySelector('#characterWbHighlightSetting').valueAsNumber * 100,
elementalConv: container.querySelector('#elementalConvHighlightSetting').valueAsNumber * 100,
multiMob: container.querySelector('#multiMobHighlightSetting').valueAsNumber * 100,
lifesteal: container.querySelector('#lifestealHighlightSetting').valueAsNumber * 100,
actionSpeed: container.querySelector('#actionSpeedHighlightSetting').valueAsNumber * 100,
mobSkip: container.querySelector('#mobSkipHighlightSetting').valueAsNumber * 100,
numGoodRolls: container.querySelector('#numGoodRolls').valueAsNumber,
numGoodRollsWb: container.querySelector('#numGoodRollsWb').valueAsNumber,
},
spaceThresholds: {
reward: container.querySelector('#rewardSpaceSetting').valueAsNumber,
mobDebuff: container.querySelector('#mobSpaceSetting').valueAsNumber,
character: container.querySelector('#characterSpaceSetting').valueAsNumber,
wb: container.querySelector('#wbSpaceSetting').valueAsNumber,
rare: container.querySelector('#rareSpaceSetting').valueAsNumber,
legendary: container.querySelector('#legendarySpaceSetting').valueAsNumber,
},
weights: {
actionSpeed: container.querySelector('#actionSpeedWeight').valueAsNumber,
mobSkip: container.querySelector('#mobSkipWeight').valueAsNumber,
multiMob: container.querySelector('#multiMobWeight').valueAsNumber,
lifesteal: container.querySelector('#lifestealWeight').valueAsNumber,
reward: container.querySelector('#rewardWeight').valueAsNumber,
mobDebuff: container.querySelector('#mobDebuffWeight').valueAsNumber,
character: container.querySelector('#characterWeight').valueAsNumber,
incomePerSpaceThreshold: container.querySelector('#incomePerSpaceThreshold').valueAsNumber,
wbCharacter: container.querySelector('#wbCharacterWeight').valueAsNumber,
wbElementalConv: container.querySelector('#wbElementalConvWeight').valueAsNumber,
wbPowerPerSpaceThreshold: container.querySelector('#wbPowerPerSpaceThreshold').valueAsNumber,
},
hideMods: {
addedMobs: container.querySelector('#hideAddedMobs').checked,
reward: settingsContainer.querySelector('#hideReward').checked,
mobDebuff: settingsContainer.querySelector('#hideMobDebuff').checked,
character: settingsContainer.querySelector('#hideCharacter').checked,
waterResistance: settingsContainer.querySelector('#hideWaterResistance').checked,
thunderResistance: settingsContainer.querySelector('#hideThunderResistance').checked,
fireResistance: settingsContainer.querySelector('#hideFireResistance').checked,
meleeResistance: settingsContainer.querySelector('#hideMeleeResistance').checked,
rangedResistance: settingsContainer.querySelector('#hideRangedResistance').checked,
elementalConversion: settingsContainer.querySelector('#hideElementalConversion').checked,
fortifyReduction: settingsContainer.querySelector('#hideFortifyReduction').checked,
},
goldKillTomesEquippedAmount: container.querySelector('#goldPerKillForTomesEquipped').valueAsNumber,
disableRefreshOnHighlight: container.querySelector('#disableRefreshOnHighlight').checked,
};
// Sanitize inputs
for (const type in tomeSettings) {
if (typeof tomeSettings[type] === 'object') {
for (const [key, value] of Object.entries(tomeSettings[type])) {
this.tomeSettings[type][key] = isNaN(value) ? this.tomeSettings[type][key] : value;
}
} else {
this.tomeSettings[type] = isNaN(tomeSettings[type]) ? this.tomeSettings[type] : tomeSettings[type];
}
}
localStorage.setItem(`${this.playerId}:QuesBS_tomeSettings`, JSON.stringify(this.tomeSettings));
this.gameData.snackbarService.openSnackbar('Store settings saved successfully');
// Refresh highlighting
const target = $('app-catacomb-tome-store > .scrollbar > div > div > .d-flex.flex-wrap.gap-1');
this.handleCatacombTomeStore({target: target[0]});
}
}
async insertFuseFrenzyButton() {
// Add a fuse button to the gem page
// Wait for page to load and get the html nodes required
let gemInvOverview = document.querySelector('app-inventory-gems');
while(!gemInvOverview) {
await new Promise(resolve => setTimeout(resolve, 50));
gemInvOverview = document.querySelector('app-inventory-gems');
}
let gemInvTopBar = gemInvOverview.firstChild.firstChild.firstChild;
// Create button
const buttonContainer = document.createElement('div');
const fuseFrenzyButton = document.createElement('div');
fuseFrenzyButton.id = 'fuseFrenzyButton';
fuseFrenzyButton.className = 'mat-focus-indicator mat-raised-button mat-button-base';
fuseFrenzyButton.innerText = 'Fuse Frenzy';
// Add button
buttonContainer.appendChild(fuseFrenzyButton);
gemInvTopBar.appendChild(buttonContainer);
fuseFrenzyButton.onclick = async () => {
await this.fuseFrenzy();
};
}
async fuseFrenzy() {
// If this.gems has no gems, get gems from code
if (this.gems.length < 1) {
// Sort by level (descending) and filter frenzy gems out
this.gems = this.gameData.playerInventoryService.gems.filter(
gem => gem.gem_type != 'frenzy' && gem.on_market === 0 && gem.trashed === 0
).toSorted(
(gemA, gemB) => gemB.gem_level - gemA.gem_level
);
}
if (this.gems.length < 3) {
// No valid gems to fuse, return early
this.gameData.snackbarService.openSnackbar(`There aren't enough gems to fuse!`);
return;
}
// take the ids of the last 3 gems (lowest level gems) and fuse
const lowestLevelGems = [this.gems.pop(), this.gems.pop(), this.gems.pop()];
const frenzyLevel = Math.round((lowestLevelGems[0].gem_level + lowestLevelGems[1].gem_level + lowestLevelGems[2].gem_level) / 3);
// Disable fuse button until gem inventory has been updated
const fuseButton = document.querySelector('#fuseFrenzyButton');
fuseButton.disabled = true;
fuseButton.className = 'mat-focus-indicator mat-stroked-button mat-button-base';
this.gameData.httpClient.post('/inventory/fuse-frenzy-gem', {gemIds: [lowestLevelGems[0].id, lowestLevelGems[1].id, lowestLevelGems[2].id]}).subscribe(
async val => {
this.gameData.snackbarService.openSnackbar(`A level ${frenzyLevel} frenzy gem was created.`); //feedback popup
fuseButton.disabled = false;
fuseButton.className = 'mat-focus-indicator mat-raised-button mat-button-base';
},
response => {
this.gameData.snackbarService.openSnackbar(`The gem failed to be created. (Gems may be out of sync with the server)`);
console.log('QuestBS: Frenzy gem could not be created.', response);
fuseButton.disabled = false;
fuseButton.className = 'mat-focus-indicator mat-raised-button mat-button-base';
}
);
}
}
// ----------------------------------------------------------------------------
// Helper functions
function getCatacombEndTime(numMobs, actionTimerSeconds, extraSeconds=0) {
const current = new Date();
const options = {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23',
};
const finishTime = new Date(current.getTime() + (numMobs * actionTimerSeconds + extraSeconds) * 1000)
.toLocaleString('en-US', options);
return finishTime;
}
function getStatRatios(statBlockElem) {
/* Given an element statBlockElem containing rows of the 4 stats displayed at
the top of the page, return the ratios between the stats
*/
const stats = [];
for (let i = 0; i < statBlockElem.children.length; i++) {
const row = statBlockElem.children[i];
stats.push(parseNumber(row.children[1].firstChild.innerText));
}
const minStat = Math.min(...stats);
return [
(stats[0] / minStat).toFixed(2),
(stats[1] / minStat).toFixed(2),
(stats[2] / minStat).toFixed(2),
(stats[3] / minStat).toFixed(2),
];
}
function parseNumber(num) {
/**
* Given a num (string), detect the type of number formatting it uses and then
* convert it to the type Number.
**/
// First strip any commas
const resultNumStr = num.replace(/,/g, '');
if (!isNaN(Number(resultNumStr))) { // This can also convert exponential notation
return Number(resultNumStr);
}
// Check if string has suffix
const suffixes = ["k", "m", "b", "t", "qa", "qi", "sx", "sp"];
const suffixMatch = resultNumStr.match(/[a-z]+\b/g);
if (suffixMatch) {
const suffix = suffixMatch[0];
const shortenedNum = parseFloat(resultNumStr.match(/[0-9.]+/g)[0]);
const multiplier = 1000 ** (suffixes.findIndex(e => e === suffix) + 1)
if (multiplier < 1000) {
console.log('QuesBS: ERROR, number\'s suffix not found in existing list');
return 0;
} else {
return shortenedNum * multiplier;
}
}
}
function addInvisibleScrollDiv() {
/**
* Add an invisible div to stop the window from scrolling via the spacebar
*/
const invisiDiv = document.createElement('button');
invisiDiv.id = 'stopScrollDiv';
invisiDiv.onclick = () => {};
invisiDiv.style.width = '0px';
invisiDiv.style.height = '0px';
invisiDiv.style.opacity = 0;
document.body.appendChild(invisiDiv);
}
// ----------------------------------------------------------------------------
// This is where the script starts
var QuesBS = null;
console.log('QuesBS: Init load');
let QuesBSLoader = null;
let numAttempts = 30;
QuesBSLoader = setInterval(setupScript, 3000);
window.startQuesBS = () => { // If script doesn't start, call this function (ie. startQuesBS() in the console)
QuesBSLoader = setInterval(setupScript, 3000);
}
window.restartQuesBS = () => { // Try to reload the game data for the script
QuesBSLoader = setInterval(async () => {
if (QuesBS.gameData === undefined) {
await QuesBS.getGameData();
} else {
clearInterval(QuesBSLoader);
console.log('QuesBS: Script has been reloaded.')
}
}, 3000);
}
async function setupScript() {
if(QuesBS === null) {
QuesBS = new Script();
await QuesBS?.getGameData();
}
if(QuesBS !== null && QuesBS.gameData !== undefined) {
console.log('QuesBS: The script has been loaded.');
clearInterval(QuesBSLoader);
await QuesBS.initPathDetection();
await QuesBS.initPlayerData();
} else {
await QuesBS?.getGameData();
console.log('QuesBS: Loading failed. Trying again...');
numAttempts--;
if(numAttempts <= 0) {
clearInterval(QuesBSLoader); //Stop trying after a while
console.log('QuesBS: Loading failed. Stopping...');
}
}
}