Queslar Betterment Script

A script that lets you know more info about quests and other QOL improvements

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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