FarmRPG Helper-dev

QOL and helper scripts for FarmRPG

当前为 2023-07-02 提交的版本,查看 最新版本

// ==UserScript==
// @name         FarmRPG Helper-dev
// @namespace    https://greasyfork.org/users/1114461
// @version      0.030
// @description  QOL and helper scripts for FarmRPG
// @author       Fewfre
// @license      GNU GPLv3
// @match        https://farmrpg.com/index.php
// @icon         https://www.google.com/s2/favicons?sz=64&domain=farmrpg.com
// @grant        none
// ==/UserScript==

(function($, app) {
    function randomNumber(min, max) {
        return Math.random() * (max - min) + min;
    }

  // time in seconds
	async function sleep(time, max) {
  	return new Promise(resolve=>{
    	setTimeout(resolve, max ? randomNumber(time*1000, max*1000) : time*1000);
    });
  }
  // https://stackoverflow.com/a/61511955
  function waitForElm(selector, options={}) {
    return new Promise((resolve, reject) => {
        if ($(selector).is(':visible') && $(selector).css('opacity') > 0.1) {
            return resolve($(selector+':visible').first()[0]);
        }

        const observer = new MutationObserver(mutations => {
            if ($(selector).is(':visible') && $(selector).css('opacity') > 0.1) {
                resolve($(selector+':visible').first()[0]);
                observer.disconnect();
            }
        });

        if(options.timeout) {
            sleep(options.timeout).then(()=>{
                observer.disconnect();
                reject(new Error("observer timed out"));
            });
        }

        observer.observe(options.target || document.body, {
            attributes: true,
            childList: true,
            subtree: true,
            ...options.config
        });
    });
  }

    async function buyItem(id, qty) {
        return fetch(`worker.php?go=buyitem&id=${id}&qty=${qty}`, { method:'POST' })
            .then(r=>r.text())
            .then(data=>{
                  // If number is returned then we bought to much - buy again at specified amount
                  if(!Number.isNaN(Number.parseInt(data))) { return buyItem(id, data); }
                  return data;
            });
    }

    function refreshPage() {
        app.mainView.router.refreshPage();
    }

  /////////////////////////////
  // Wordle Solver
  /////////////////////////////
    const WordleSolver = (() => {
  // https://raw.githubusercontent.com/gamescomputersplay/wordle/main/wordle.py
  // https://www.youtube.com/watch?v=sVCe779YC6A

  const COLUMNS = 4;
  const ALPHABET = "0123456789".split(""); //"abcdefghijklmnopqrstuvwxyz";
  const ALL_WORDS = Array.from({ length: Math.pow(10, COLUMNS) }).map((_, i) => i.toString().padStart(COLUMNS, "0")); // 0000-9999

  const LOC = {
    WRONG: 0,
    PARTIAL: 1,
    CORRECT: 2,
  };

  const arrGen = (len, fill) => Array.from({ length: len }).fill(fill);
  const randomChoice = (arr) => arr[Math.floor(Math.random() * arr.length)];
  const countInstances = (arr, findMe) => Array.from(arr).filter((v) => v == findMe).length;
  const arrRemove = (arr, delMe) => (i = arr.indexOf(delMe)) > -1 && arr.splice(i, 1)[0];

  // https://stackoverflow.com/a/33034768
  const difference = (arr1, arr2) => Array.from(arr1).filter((x) => !Array.from(arr2).includes(x));

  class WordList {
    /**
     * Class to load the list of words from file
     * Initialized with the file(s) to load words from
     * @param {()=>string[]} filesCallback
     */
    constructor(filesCallback) {
      // list of all the words
      this.word_list = filesCallback?.() ?? [];

      // letter counts in all words in the list: {"a": 100, "b": 200, ...}
      this.letter_count = {};

      // words' scores: {"apple": 100, "fruit": 200} etc
      // score is the sum of all letters' frequencies
      this.word_scores = {};

      // Same, but scores account for letter positions
      this.position_letter_count = arrGen(COLUMNS).map(() => ({}));
      this.position_word_scores = {};

      // Generate the word scores
      // (both positional and total)
      this.gen_word_scores();
      this.gen_positional_word_scores();
    }

    /**
     * Copy of existing word list
     * @returns {WordList}
     */
    copy() {
      let new_word_list = new WordList();
      new_word_list.word_list = this.word_list.slice();
      new_word_list.word_scores = { ...this.word_scores };
      new_word_list.position_word_scores = { ...this.position_word_scores };
      return new_word_list;
    }

    /**
     * Return count of remaining words: len(word_list)
     * @returns {number} Length of word list
     */
    get length() {
      return this.word_list.length;
    }

    /**
     * Return random word from the word list
     * @returns
     */
    get_random_word() {
      return randomChoice(this.word_list);
    }

    /**
     * Return the word with the highest score
     * @param {boolean} use_position whether or not use position-based scores
     * @returns
     */
    get_hiscore_word(use_position = false) {
      const scores = use_position ? this.position_word_scores : this.word_scores;
      let best_word = "";
      let best_score = 0;
      for (let word of this.word_list) {
        if (scores[word] > best_score) {
          best_score = scores[word];
          best_word = word;
        }
      }
      return best_word;
    }

    /**
     * Return the word with maximized number of unique "letters"
     * @param {string[]} maximized_letters
     * @returns
     */
    get_maximized_word(maximized_letters) {
      this.gen_letter_count();
      let best_word = "";
      let best_score = 0;
      for (let word of this.word_list) {
        let this_score = 0;
        for (let letter of maximized_letters) {
          if (word.indexOf(letter) > -1) {
            this_score += 1;
          }
        }
        if (this_score > best_score) {
          best_score = this_score;
          best_word = word;
        }
      }
      return best_word;
    }

    /**
     * Calculate counts of all letters in the word_list
     */
    gen_letter_count() {
      this.letter_count = Object.fromEntries(ALPHABET.map((c) => [c, 0]));
      for (let word of this.word_list) {
        for (let letter of new Set(word)) {
          this.letter_count[letter] += 1;
        }
      }
    }

    /**
     * calculate letter count for each letter position
     */
    gen_positional_letter_count() {
      for (let i = 0; i < COLUMNS; i++) {
        this.position_letter_count[i] = Object.fromEntries(ALPHABET.map((c) => [c, 0]));
      }
      for (let word of this.word_list) {
        Array.from(word).forEach((letter, i) => {
          this.position_letter_count[i][letter] += 1;
        });
      }
    }
    /**
     * Calculate scores for each word
     */
    gen_word_scores() {
      this.gen_letter_count();
      this.word_scores = {};
      for (let word of this.word_list) {
        let word_score = 0;
        for (let letter of new Set(word)) {
          word_score += this.letter_count[letter];
        }
        this.word_scores[word] = word_score;
      }
    }

    /**
     * Calculate positional scores for each word
     */
    gen_positional_word_scores() {
      this.gen_positional_letter_count();
      this.position_word_scores = {};
      for (let word of this.word_list) {
        // Sum up scores, but if the letter is twice in the word
        // use the highest score only
        let word_score = {};
        Array.from(word).forEach((letter, i) => {
          if (word_score[letter] !== undefined) {
            word_score[letter] = this.position_letter_count[i][letter];
          } else {
            word_score[letter] = Math.max(word_score[letter], this.position_letter_count[i][letter]);
          }
        });
        this.position_word_scores[word] = Object.values(word_score).length;
      }
    }

    /**
     * Removing words from the word list,
     * by checking with teh three masks
     * @param {string[][]} yes_mask
     * @param {string[][]} no_mask
     * @param {Set<string>[]} allowed_mask
     */
    filter_by_mask(yes_mask, no_mask, allowed_mask) {
      let new_words = [];
      for (let word of this.word_list) {
        // Yes_mask: should have that letter in that place
        let noLettersMissingFromYesMask = !yes_mask.some(
          (must_have_letters, n) => !!must_have_letters.length && word[n] != must_have_letters[0]
        );
        if (noLettersMissingFromYesMask) {
          let noForbiddenLettersFound = true;
          // No_mask: should NOT have that letter in that place
          for (let n = 0; n < no_mask.length; n++) {
            let fail = false;
            const forbidden_letters = no_mask[n];
            for (let forbidden_letter of forbidden_letters) {
              if (word[n] == forbidden_letter) {
                fail = true;
              }
            }
            if (fail) {
              noForbiddenLettersFound = false;
              break;
            }
          }
          if (noForbiddenLettersFound) {
            // Allowed mask: should have allowed count of letters
            if (!ALPHABET.some((letter) => !allowed_mask[countInstances(word, letter)].has(letter))) {
              new_words.push(word);
            }
          }
        }
      }
      this.word_list = new_words;
    }
  }

  class Guess {
    /**
     * Class for one guess attempt
     * Contains the guessed word and list of results
     * @param {string} guess_word
     * @param {string?} correct_word
     * @param {(0|1|2)[]?} guess_result
     */
    constructor(guess_word, correct_word, guess_result) {
      /**
       * @type {string}
       */
      this.word = guess_word;
      // Set to True, but will be switched
      this.result = guess_result ?? this.get_result(correct_word);
      this.guessed_correctly = countInstances(this.result, LOC.CORRECT) === COLUMNS;
    }

    /**
     * String representation looks like: ducky: G__Y_
     * G, Y, _ is for green / yellow / grey
     * @returns
     */
    toString() {
      let out = `${this.word}: `;
      for (let letter_result of this.result) {
        if (letter_result == 2) {
          out += "G";
        } else if (letter_result == 1) {
          out += "Y";
        }
        if (letter_result == 0) {
          out += "_";
        }
        return out;
      }
    }

    /**
     * Given the guessed and the right word
     * generate the list of letter results:
     * 0/1/2 meaning no/misplaced/correct
     * @returns {(0|1|2)[]}
     */
    get_result(correct_word) {
      let result = arrGen(COLUMNS, LOC.WRONG);
      // we are using a copy to blank guessed green and yellow
      // letters (to correctly display doubles)
      let correct_copy = [...correct_word];

      Array.from(this.word).forEach((guessed_char, i) => {
        if (guessed_char == correct_copy[i]) {
          result[i] = LOC.CORRECT;
          correct_copy[i] = "";
        }
      });

      for (let i = 0; i < this.word.length; i++) {
        if (correct_copy.includes(this.word[i]) && result[i] != LOC.CORRECT) {
          result[i] = LOC.PARTIAL;
          for (let j = 0; j < COLUMNS; j++) {
            if (correct_copy[j] == this.word[i]) {
              correct_copy[j] = "";
              break;
            }
          }
        }
      }
      if (countInstances(result, LOC.CORRECT) === COLUMNS) {
        this.guessed_correctly = true;
      }
      return result;
    }
  }

  class Wordle {
    /**
     * Class representing one wordle game.
     * methods include initiating a secret word,
     * returning green/yellow/grey results,
     * keeping track of guessed letters
     * @param {string|"#random"|null} correct_word
     */
    constructor(correct_word = "#random") {
      // the word to guess
      if (correct_word === "#random") {
        this.correct_word = puzzle_words.get_random_word();
      } else if (puzzle_words.word_list.indexOf(correct_word) > -1) {
        this.correct_word = correct_word;
      }
      // else leave as null

      // list of guesses so far
      /**
       * @type {Guess[]}
       * @public
       */
      this.guesses = [];
    }
    toString() {
      return `::${this.correct_word}::` + this.guesses.map((guess, i) => `\n${i + 1}. ${guess}`).join("");
    }
    /**
     * One turn of the game
     * get guessed word, add new Guess in guesses list
     * if guessed correctly, return True, else False
     * @param {string} word
     * @param {(0|1|2)[]?} result
     */
    guess(word, result) {
      this.guesses.push(new Guess(word, this.correct_word, result));
      // Return True/False if you got the word right
      return this.guesses.at(-1).guessed_correctly;
    }
  }

  class Player {
    /**
     * Default player (random)
     * Guesses a random word from the whole list
     * @param {WordList} guessing_words
     */
    constructor(guessing_words) {
      // Mask
      // Yes mask: this letters should be in these places
      /**
       * @type {string[][]}
       */
      this.yes_mask = arrGen(COLUMNS).map(() => []);
      // No mask: this letters should NOT be in these places
      /**
       * @type {string[][]}
       */
      this.no_mask = arrGen(COLUMNS).map(() => []);

      // Count mask: Word can have (n) such letters
      // [[letters that can be 0 of], [1 of], [2 of], [3 of]]
      /**
       * @type {Set<string>[]}
       */
      // this.allowed_mask = arrGen(COLUMNS - 1).map(() => new Set(ALPHABET));
      this.allowed_mask = arrGen(COLUMNS + 1).map(() => new Set(ALPHABET));

      // which letter has to be in the word, from green and yellow letters
      this.must_use = new Set();

      // copy of the global word set (we'll be removing unfit words from it)
      /**
       * @type {WordList}
       */
      this.remaining_words = guessing_words.copy();
    }
    /**
     * Removing words from the word list, that don't fit with
     * what we know about the word (using mask and must_use)
     */
    filter_word_list() {
      this.remaining_words.filter_by_mask(this.yes_mask, this.no_mask, this.allowed_mask);
    }
    /**
     * Try to re-use "green" space by putting some remaining letters there
     */
    reuse_green() {
      // Count vowels in teh list of letter
      // function count_vowels(letters) {
      //   let count = 0;
      //   let vowels = new Set("aoieu");
      //   for (let letter of letters) {
      //     if (vowels.indexOf(letter) > -1) {
      //       count += 1;
      //     }
      //   }
      //   return count;
      // }
      // Temp Yes mask is empty
      let temp_yes_mask = arrGen(COLUMNS).map(() => []);

      // Temp No mask is actual Yes mask
      let temp_no_mask = this.yes_mask;

      // Prioritize those that are present in all "allowed _mask[1]"
      // (meaning they have never been grey) minus all yellow and greens
      let greens_n_yellows = new Set();
      this.yes_mask.concat(this.no_mask).forEach((letters) => {
        for (let letter of letters) {
          greens_n_yellows.add(letter);
        }
      });

      // Add vowels if needed
      let priority_letters = new Set(difference(this.allowed_mask[1], greens_n_yellows));
      let letters_for_allowed_mask = priority_letters;
      // if (count_vowels(priority_letters) == 0) {
      //   letters_for_allowed_mask = new Set([...priority_letters, new Set("aoe")]);
      // }

      // Temp Allowed mask: priority letters and some vowels
      // [0] has all letters - any letter can be missed
      let temp_allowed_mask = [new Set(ALPHABET), ...arrGen(COLUMNS).map(() => new Set(letters_for_allowed_mask))];

      // Find the word to fit temporary mask, with maximized prioritized letters
      let temp_words = guessing_words.copy();
      temp_words.filter_by_mask(temp_yes_mask, temp_no_mask, temp_allowed_mask);
      if (temp_words.length > 0) {
        return temp_words.get_maximized_word(Array.from(priority_letters));
      }
      return "";
    }

    /**
     * Pick the word from the list
     * @returns
     */
    make_guess() {
      // Use random word if:
      // 1. "scored" is no set
      // 2. "firstrandom" is set and this is the first guess
      // (word list has not been filtered yet)
      if (
        !params.includes("scored") ||
        (params.includes("firstrandom") && this.remaining_words.length == guessing_words.length)
      ) {
        return this.remaining_words.get_random_word();
      }

      // list of masks' lengths
      let has_greens = COLUMNS - this.yes_mask.filter((y) => y == []).length;
      // Conditions for "re-use green" logic:
      // has Green; more than 2 potential answers
      if (params.includes("easymode") && has_greens > 0 && this.remaining_words.length > 2) {
        // if reusing green is successful, return that word
        let reuse_green_word = this.reuse_green();
        if (reuse_green_word != "") return reuse_green_word;
      }

      // recount / don't recount all scores
      if (params.includes("recount")) {
        this.remaining_words.gen_word_scores();
        this.remaining_words.gen_positional_word_scores();
      }
      // use / don't use position letter weights
      if (params.includes("position")) {
        return this.remaining_words.get_hiscore_word(true);
      }
      return this.remaining_words.get_hiscore_word(false);
    }

    /**
     * Track letters that should be in this place (from green)
     * @param {Guess} guess
     */
    update_yes_mask(guess) {
      guess.result.forEach((letter_result, i) => {
        if (letter_result == LOC.CORRECT) {
          // green: should have this letter here
          if (!this.yes_mask[i].includes(guess.word[i])) {
            this.yes_mask[i].push(guess.word[i]);
          }
        }
      });
    }
    /**
     * Track letters that should not be in this place (from yellow)
     * @param {Guess} guess
     */
    update_no_mask(guess) {
      // Delete the letter in the same place in the mask
      guess.result.forEach((letter_result, i) => {
        if (letter_result == LOC.PARTIAL) {
          // yellow: should not have this letter here
          if (!this.no_mask[i].includes(guess.word[i])) {
            this.no_mask[i].push(guess.word[i]);
          }
        }
        // This is grey, but not the only letter in the word
        if (letter_result == LOC.WRONG && countInstances(guess.word, guess.word[i]) > 1) {
          if (!this.no_mask[i].includes(guess.word[i])) {
            this.no_mask[i].push(guess.word[i]);
          }
        }
      });
    }

    /**
     * Track how many which letters should be in the word
     * @param {Guess} guess
     * @returns
     */
    update_allowed_mask(guess) {
      // count colors for each letter, like this
      // {"a":[2,0], "b":[2,1], "c":[0]}
      let letter_count = {};
      Array.from(guess.word).forEach((letter, i) => {
        if (letter_count[letter]) {
          letter_count[letter].push(guess.result[i]);
        } else {
          letter_count[letter] = [guess.result[i]];
        }
      });
      // Go through each letter count and update count_mask
      Object.entries(letter_count).forEach(([letter, stats]) => {
        // Case Grey:
        // Word should have no more that {count of other numbers except 0}
        // of this letter. e.g. [0] - none [2,0] - 1, [2,1,0] - 2
        if (stats.includes(LOC.WRONG)) {
          let allowed_count = stats.length - countInstances(stats, LOC.WRONG);
          // for (let i = allowed_count + 1; i < COLUMNS - 1; i++) {
          for (let i = allowed_count + 1; i < COLUMNS; i++) {
            this.allowed_mask[i].delete(letter);
          }
        }
        // Case Yellow / Green
        // Word should have at leaset {count of 1&2s letters} of these
        if (stats.includes(LOC.PARTIAL) || stats.includes(LOC.CORRECT)) {
          let required_count = countInstances(stats, LOC.PARTIAL) + countInstances(stats, LOC.CORRECT);
          for (let i = 0; i < required_count; i++) {
            this.allowed_mask[i].delete(letter);
          }
        }
      });
    }

    /**
     * Combined mask updating functions
     * @param {Guess} guess
     */
    update_mask_with_guess(guess) {
      this.update_yes_mask(guess);
      this.update_no_mask(guess);
      this.update_allowed_mask(guess);
    }

    /**
     * Update allowed_mask, based on letter freq of remaining words
     */
    update_mask_with_remaining_words() {
      // Update allow_mask, knowing letter count of remaining words
      this.remaining_words.gen_letter_count();
      Object.entries(this.remaining_words.letter_count).forEach(([letter, count]) => {
        // If there is no such words in the whole list
        // remove it from mask
        if (count == 0) {
          // for (let i = 1; i < COLUMNS - 2; i++) {
          for (let i = 1; i < this.allowed_mask.length; i++) {
            this.allowed_mask[i].delete(letter);
          }
        }
      });
    }

    /**
     * Remove a word from possible guesses used to remove used words
     * @param {string} word
     */
    remove_word(word) {
      arrRemove(this.remaining_words.word_list, word);
    }
  }

  function realtimeGameSolver() {
    const game = new Wordle(null);
    const player = new Player(guessing_words);

    return {
      getGuess() {
        return player.make_guess();
      },
      submitResult(players_guess, result) {
        // Play the guess, see if we are done
        const done = game.guess(
          players_guess,
          Array.from(result).map((n) => parseInt(n))
        );

        // Post-guess action:
        // Remove the words we just played
        player.remove_word(players_guess);
        // Update mask with guess results
        player.update_mask_with_guess(game.guesses.at(-1));

        // Filter the word down according to new mask
        player.filter_word_list();

        // Update the mask according to remaining words
        player.update_mask_with_remaining_words();

        return done;
      },
    };
  }

  /**
 * Playing one round of Wordle using player strategy\n' +
'    from PlayerType
 * @param {boolean} quiet
 * @param {*} correct_word
 * @returns
 */
  function play_one_game(quiet = true, correct_word = null) {
    if (!quiet) console.log("game started");
    const game = new Wordle("9876");
    const player = new Player(guessing_words);
    let done = false;

    // Cycle until we are done
    let count = 10;
    while (!done && count > 0) {
      // Make a guess
      let players_guess = player.make_guess();

      // Play the guess, see if we are done
      if (game.guess(players_guess)) {
        done = true;
      }
      // Post-guess action:
      // Remove the words we just played
      player.remove_word(players_guess);
      // Update mask with guess results
      player.update_mask_with_guess(game.guesses.at(-1));

      // Filter the word down according to new mask
      player.filter_word_list();

      // Update the mask according to remaining words
      player.update_mask_with_remaining_words();
      count--;
    }

    if (!quiet) console.log(game);

    if (game.guesses.at(-1).guessed_correctly) {
      return game.guesses;
    }
    return -1; // This shouldn't happen
  }

  /**
   * Get couple of main statistics from the list of results
   * @param {any[]} results
   */
  function parse_results(results) {
    let frequencies = {};
    let lengths = [];
    let complete = 0;
    let turns_sum = 0;
    for (let result of results) {
      let length = result.length;
      lengths.push(length);
      if (frequencies[length] !== undefined) {
        frequencies[length] += 1;
      } else {
        frequencies[length] = 1;
      }
      turns_sum += length;
      if (length <= MAX_TURNS) {
        complete += 1;
      }
    }
    console.log(`Wins: ${complete}, Losses: ${results.length - complete}`);
    console.log(`Winrate: ${(complete * 100) / results.length}%`); // console.log(`Winrate: ${complete*100/len(results):.1f}%`);

    if (complete > 0) {
      console.log(`Average length: ${turns_sum / results.length}`); // console.log(`Average length: ${turns_sum/len(results):.1f}`);
    }
    console.log(`Median length: ${lengths.sort()[Math.floor(results.length / 2)]}`);
  }

  /**
   * launch the simulation
   */
  function main() {
    let start_time = Date.now();
    if (N_GAMES == 1) {
      play_one_game(false);
    } else {
      simulation(N_GAMES);
    }
    console.log(`Time: ${Date.now() - start_time}`);
  }

  // Word lists to use:
  // List that wordle game uses as a target word
  const puzzle_words = new WordList(() => ALL_WORDS);
  // List that the "player" program uses
  const guessing_words = new WordList(() => ALL_WORDS);

  // Game length (the game will go on, but it will affect the % of wins)
  const MAX_TURNS = 6;

  // Player's settings:
  // With everything off uses the naive greedy method (limit the potential
  // answers and randomly chose a word from the remaining list)
  // "scored": weight words by the frequency of the words
  //   "recount": recalculate weights for every guess
  //   "firstrandom": random first guess
  //       (worse results but more interesting to watch)
  //   "position": use positional letter weights
  // "easymode": don't have to use current result (reuse green space)
  const params = ["scored", "recount", "firstrandom_off", "position", "easymode"];

  // Number of games to simulate
  // if == 1, plays one random game, shows how the game went
  // if == 2315, runs simulation for all Wordle words (for deterministic methods)
  // other numbers - play N_GAMES games with random words from puzzle_words
  const N_GAMES = 1; //2315;

  // main();
  return {
    realtimeGameSolver,
  };
})();

  /////////////////////////////
  // Assets
  /////////////////////////////

  // uri conversion: https://dopiaza.org/tools/datauri/index.php
  // https://pixabay.com/sound-effects/finished-45049/
  const SOUND_FINISHED = new Audio("data:audio/mpeg;base64,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XQDigEAQOE61LX5iKKY6TEFNRTMuMTAwqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq/+OIZAAAAAGkAOAAAAAAA0gBwAAATEFNRTMuMTAwqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqTEFNRTMuMTAwqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq");

  /////////////////////////////
  // Mailbox Passwords
  /////////////////////////////
  const TEXT_MAILBOX_PWD_BTN = `🤖 CHEAT <abbr title="Passwords fetched from the buddy.farm passwords list: https://buddy.farm/passwords/">ⓘ</abbr>`;

    async function getMailboxPasswords() {
        return fetch("https://buddy.farm/page-data/passwords/page-data.json").then(res=>res.json())
            .then(json=>json.result.data.farmrpg.passwords.map(pw=>pw.password));
    }
    async function getUsedMailboxPasswords() {
        return new Promise((resolve)=>{
            $('<div/>').load('popwlog.php', function() {
                resolve( $(this).find(".list-block .item-title span:first-of-type").map((_,el)=>$(el).text()).get() );
            });
        });
    }
    async function initMailboxPasswords() {
      let allPasswords, usedPasswords;
      app.onPageInit("postoffice", ({ container })=>{
        $(`<button style="white-space:nowrap;" />`).html(TEXT_MAILBOX_PWD_BTN).appendTo($("#popw").closest(".item-inner")).on("click", async function(){
            $(this).html("Loading...");
            allPasswords ??= await getMailboxPasswords();
            usedPasswords ??= await getUsedMailboxPasswords();
            $(this).html(TEXT_MAILBOX_PWD_BTN);

            const passwords = allPasswords.filter(pw=>!usedPasswords.includes(pw));
            if(passwords.length <= 0) { $(this).attr('disabled', 'disabled').html("ALL USED"); return; }
            $("#popw").val(passwords[0]);
        });
          $(".popwbtn").on("click", function(){ usedPasswords.push( $("#popw").val() ); });
      });
    }

  /////////////////////////////
  // Vault
  /////////////////////////////
    const vaultTypeMap = { 'G':0, 'Y':1, 'B':2 };
    function initVault() {
      app.onPageInit("crack", ({ container })=>{
        $("<button />").html("🤖 GUESS").appendTo($("#vaultcode").closest(".item-inner")).on("click", function(){
            const solver = WordleSolver.realtimeGameSolver();
            // find current guesses
            const prevGuesses = $(".col-25[data-type]").closest(".card-content-inner").find(".row").map((_,row) => {
                return {
                    cellsResult: $(row).find("[data-type]").map((_,g)=>vaultTypeMap[$(g).data('type')]).get(),
                    cellsGuess: $(row).find("[data-type]").map((_,g)=>$(g).text()).get(),
                };
            }).get();
            prevGuesses.forEach(prev=>solver.submitResult(prev.cellsGuess.join(""), prev.cellsResult));
            $("#vaultcode").val(solver.getGuess());
        })
      });
    }

  /////////////////////////////
  // Fishing
  /////////////////////////////
  const TEXT_FISHING_START = "🤖 AUTO FISH";
  const TEXT_FISHING_STOP = "🤖❌ STOP FISHING";
  const TEXT_FISHING_STOPPING = "🤖❌ STOPPING";

  function getBaitCount() {
  	return parseInt($("#baitarea strong").first().text() || 0);
  }

    async function getAllBaitData() {
        return new Promise((resolve)=>{
            $('<div/>').load('changebait.php?from=fishing&id='+$('.zone_id').html(), function() {
                resolve( $(this).find(".selectbait").map(function(){ return {
                    name: $(this).data('bait'),
                    num: $(this).find(".item-after").text(),
                    imgSrc: $(this).find("img").attr('src'),
                    selected: $(this).find(".item-title i").text() === 'check',
                } }).get() );
            });
        });
    }

	async function clickFish() {
    if(!$("#fishinwater").length) {
      throw new Error("No fishing area detected")
    }
    const fish = await waitForElm(".fish.catch");
  	fish.click();
/*     var triesLeft = 500;
    while (triesLeft-- > 0) {
      if($(".fish.catch").length) {
        $(".fish.catch").click();
            return;
      }
      await sleep(0.05);
    } */
/*     if(triesLeft <= 0) {
      throw new Error("Script gave up, couldn't find fish")
    } */
  }

  async function catchFish() {
    if(!$(".fishcaught").length) {
      throw new Error("Cannot find fishing to catch")
    }
    $(".fishcaught").trigger("click");
  }

  async function fishOne() {
  	await clickFish();
    // We wait for catch modal - but if it doesn't open then we missed the fish and try again
  	try {
        await waitForElm(".picker-catch.modal-in", { timeout:0.1 });
    } catch(err) {
        if(!fishing) { return; }
        return await fishOne();
    };
    await sleep(0.33); // time for modal to animate in + small buffer
    if(!fishing) { catchFish(); return; }
    await sleep(0.23, 0.888);
  	await catchFish();
    await sleep(0.05, 0.115); // tiny delay before clicking next fish
  }

  let fishing = false;
  async function startFishing() {
    fishing = true;
    try {
      if(!$("#fishinwater").length) {
        throw new Error("No fishing area detected");
      }
      while (getBaitCount() > 0) {
          await fishOne();
          if(!fishing) {
              break;
          }
          await sleep(0.3, 0.4);
      }
      fishing = false;
      SOUND_FINISHED.play();
      $("#pogfishing").html(TEXT_FISHING_START);
      $("#pogfishing").removeAttr('disabled');
    }
    catch(e) {
    	alert(e.message);
    }
  }

    function initFishing() {
      app.onPageInit("fishing", ({ container })=>{
          $(`<button id="pogfishing">${fishing ? TEXT_FISHING_STOP : TEXT_FISHING_START}</button>`).appendTo(container.querySelector(".buttons-row")).on("click", function(){
              if($("#pogfishing").attr('disabled')) { return; }
              if(!fishing) {
                  $("#pogfishing").html(TEXT_FISHING_STOP);
                  startFishing();
              } else {
                  $("#pogfishing").html(TEXT_FISHING_STOPPING);
                  $("#pogfishing").attr('disabled','disabled');
                  fishing = false;
              }
          });

          const $contOuter = $(`<div class="card-content-inner" style="padding:5px"></div>`).insertAfter($(container).find('#baitarea'));
          const $contOuterRow = $(`<div class="row" style="margin-bottom: 0"></div>`).appendTo($contOuter);
          const $cont = $(`<div style="display:flex;" />`).appendTo($contOuterRow);

          const $baitList = $(`<div style="display:flex; gap: 10px; margin-right:30px;">Quick Swap: <span class="loadn">Loading...</span></div>`).appendTo($cont)

          $(`<button id="pogBuyWorms">BUY 200 WORMS</button>`).appendTo($cont).on("click", function(){
              if($("#pogBuyWorms").attr('disabled')) { return; }
              $("#pogBuyWorms").attr('disabled','disabled');
              buyItem(18, 200).then(data=>{
                  if(data=="success" || data==="") {
                      $("#pogBuyWorms").removeAttr('disabled');
                      refreshPage();
                  } else {
                      $("#pogBuyWorms").text(data);
                  }
              }).catch(console.error);
          });

          getAllBaitData().then((baits)=>{
              baits = baits.filter(b=>!b.selected);
              $baitList.find(".loadn").remove();
              $baitList.append(baits.length <= 0
                    ? "No other baits available"
                    : baits.map(bait=>$(`<span title="${bait.name}" style="cursor:pointer;"><img src="${bait.imgSrc}" height="14" />&thinsp;${bait.num}</span>`)
                        .on('click', ()=>{
                            fetch(`worker.php?go=selectbait&bait=${bait.name}`, { method:'POST' }).then(r=>r.text()).then(res=>{
                                res === 'success' ? refreshPage() : $baitList.html(`ERROR: ${res}`);
                            });
                        } ))
              );
          });
      });
    }

  /////////////////////////////
  // Exploration
  /////////////////////////////
  const TEXT_EXPLORING_START = "AUTO EXPLORE";
  const TEXT_EXPLORING_STOP = "❌ STOP EXPLORING";
  const TEXT_EXPLORING_STOPPING = "❌ STOPPING";

  function getStaminaCount() {
  	return parseInt($("#stamina").text() || 0)
  }

  async function exploreOne() {
      if(!$(".explorebtn").length) {
        throw new Error("No exploration area detected");
      }
    $(".explorebtn").trigger("click");
  }

  let exploring = false;
  async function startExploring() {
    exploring = true;
    try {
      if(!$(".explorebtn").length) {
        throw new Error("No exploration area detected");
      }
      while (getStaminaCount() > 0) {
		  await exploreOne();
          if(!exploring) { break; }
    	  await sleep(0.12, 0.28);
      }
      exploring = false;
      SOUND_FINISHED.play();
      $("#pogexploring .item-inner").html(TEXT_EXPLORING_START);
      $("#pogexploring").removeAttr('disabled');
    }
    catch(e) {
    	alert(e.message);
    }
  }

    function initExploring() {
        app.onPageInit("area", ({ container })=>{
          $(`<li>
<div class="item-content" style="cursor:pointer" id="pogexploring">
<div class="item-media">🤖</div>
<div class="item-inner">${exploring ? TEXT_EXPLORING_STOP : TEXT_EXPLORING_START}</div>
</div>
</li>`).insertAfter(container.querySelector("li:has(.explorebtn)")).on("click", function(){
              if($("#pogexploring").attr('disabled')) { return; }
              if(!exploring) {
                  $("#pogexploring .item-inner").html(TEXT_EXPLORING_STOP);
                  startExploring();
              } else {
                  $("#pogexploring .item-inner").html(TEXT_EXPLORING_STOPPING);
                  $("#pogexploring").attr('disabled','disabled');
                  exploring = false;
              }
          });
      });
    }

  /////////////////////////////
  // Navigation
  /////////////////////////////
    function initShortcuts() {
        $(`<li>
<div class="item-content">
<div class="item-inner">

<div style="display:grid; grid-template-columns: 1fr auto;">
<div><i class="fa fa-fw fa-lightbulb-o" /></div>
<div>
<div class="item-title">&nbsp; Shortcuts</div>
<div style="font-size: 14px;">${[
            {
                links: [
                    { link:'xfarm', text:'Farm', params:{ id: $('.view-main a[href^="xfarm.php?id="]').attr('href').match(/\?id=(\d*)/)[1] } },
                    { link:'explore', text:'Explore' },
                    { link:'fish', text:'Fishing' },
                    { link:'town', text:'Town' },
                    { link:'quests', text:'Help' },
                    { link:'workshop', text:'Workshop' },
                ]
            },
            {
                section: 'Town',
                links: [
                    { link:'store', text:'Store' },
                    { link:'market', text:'Sell' },
                    { link:'bank', text:'Bank' },
                    { link:'postoffice', text:'Mail' },
                    { link:'pets', text:'Pets' },
                    { link:'supply', text:'Upgrade' },
                    { link:'locksmith', text:'Locksmith' },
                    { link:'steakmarket', text:'Steak' },
                ]
            },
            {
                section: 'Daily',
                links: [
                    { link:'daily', text:'Chores' },
                    { link:'well', text:'Well' },
                    { link:'crack', text:'Vault' },
                    { link:'comm', text:'Community Center' },
                ]
            },
            {
                section: 'Skills',
                links: [
                    { link:'progress', params:{ type:'Farming' }, text:`<img src="/img/items/6137.png" height="12" />` },
                    { link:'progress', params:{ type:'Fishing' }, text:`<img src="/img/items/7783.png" height="12" />` },
                    { link:'progress', params:{ type:'Crafting' }, text:`<img src="/img/items/5868.png" height="12" />` },
                    { link:'progress', params:{ type:'Exploring' }, text:`<img src="/img/items/6075.png" height="12" />` },
                    { link:'perks', text:'Perks' },
                    { link:'mastery', text:'Mastery' },
                    { link:'npclevels', text:'Friendship' },
                ]
            },
].map(sctn=>`<div>
  ${sctn.section ? `<div style="margin-top:3px;"><strong>${sctn.section}</strong></div>` : ''}
  ${sctn.links.map(l=>`<a href='${l.link}.php${l.params ? '?'+new URLSearchParams(l.params).toString() : ''}' data-view=".view-main">${l.text}</a>`).join(` &bull; `)}
  </div>`).join('')}</div>
</div>
</div>

</div>
</div>
</li>`).insertAfter('.view.view-left.navbar-through .page-content li:first-of-type');
    }

  /////////////////////////////
  // Initialize
  /////////////////////////////
  function init() {
      initShortcuts();

      initFishing();
      initExploring();
      initVault();
      initMailboxPasswords();
      // Stop any automation scripts after you change an area
      app.onPageInit("area", ({ container })=>{
          fishing = false;
          exploring = false;
      });
  }
  init();
})(window.jQuery, window.myApp);