GC Flask Calculator

Displays probabilities of different Flask of Rainbow Fountain Water results at the Rainbow Pool at grundos.cafe.

// ==UserScript==
// @name         GC Flask Calculator
// @namespace    http://devipotato.net/
// @version      1
// @description  Displays probabilities of different Flask of Rainbow Fountain Water results at the Rainbow Pool at grundos.cafe.
// @author       DeviPotato (Devi on GC, devi on Discord)
// @license MIT
// @match        https://www.grundos.cafe/rainbowpool/neopetcolours/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=grundos.cafe
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at document-end
// ==/UserScript==

(async function() {
    const carbonatedRemovedColors = ["Blue", "Green", "Red", "Yellow", "White", "Purple", "Brown", "Pink", "Orange", "Invisible"];

    function urlParam(name){
        var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(window.location.href);
        if (results==null) {
            return null;
        }
        return decodeURI(results[1]) || 0;
    }

    function querySelectorIncludesText (selector, text){
        return Array.from(document.querySelectorAll(selector))
            .filter(el => el.textContent.includes(text));
    }
    function capitalizeFirstLetter(string) {
        return string.charAt(0).toUpperCase() + string.slice(1);
    }

    async function countColors(species) {
        let colorData = JSON.parse(await GM.getValue("colorData", "{}"))
        let normal = 0;
        let carbonated = 0;
        for (const color in colorData[species]) {
            normal += colorData[species][color];
            if(!carbonatedRemovedColors.includes(color)) {
                carbonated += 1;
            }
        }
        return { normal, carbonated };
    }

    async function calculateColorChances(species, color) {
        let colorData = JSON.parse(await GM.getValue("colorData", "{}"))
        let capsColor = capitalizeFirstLetter(color);
        let totalNormalColors = 0;
        let totalCarbonatedColors = 0;
        for (const color in colorData[species]) {
            totalNormalColors += colorData[species][color];
            if(!carbonatedRemovedColors.includes(color)) {
                totalCarbonatedColors += 1;
            }
        }
        let normalChance = colorData[species][capsColor] / totalNormalColors;
        let specificNormalChance = 1 / totalNormalColors;
        let carbonatedChance = (carbonatedRemovedColors.includes(capsColor)?0:1/totalCarbonatedColors);
        return { normalChance, specificNormalChance, carbonatedChance }
    }

    function calculateMultiflaskChances(colorChances, flasks) {
        let normalChance = 1 - Math.pow(1 - colorChances.normalChance, flasks);
        let specificNormalChance = 1 - Math.pow(1 - colorChances.specificNormalChance, flasks);
        let carbonatedChance = 1 - Math.pow(1 - colorChances.carbonatedChance, flasks);
        return { normalChance, specificNormalChance, carbonatedChance }
    }

    function colorStats(colorChances, colorName, isAlt=false) {
        let hasAlts = colorChances.normalChance != colorChances.specificNormalChance;
        let container = document.createElement("div");
        container.classList.add("flask_statscontainer");
        let stats = document.createElement("div");
        stats.classList.add("flask_stats");
        container.append(stats);
        let form = document.createElement("div");
        form.classList.add("multiflask_form");
        form.textContent = "Number of flasks: ";
        container.appendChild(form);
        let flaskCount = document.createElement("input");
        flaskCount.type = "text";
        flaskCount.inputmode = "numeric";
        flaskCount.pattern = "[0-9]*";
        flaskCount.value = 1;
        flaskCount.classList.add("multiflask_count","form-control");
        flaskCount.size = 5;
        //workaround to prevent auto-focus on mobile
        flaskCount.disabled = true;
        flaskCount.autofocus = false;
        form.appendChild(flaskCount);
        form.appendChild(document.createTextNode(" "));
        let calculateButton = document.createElement("input");
        calculateButton.type = "button";
        calculateButton.classList.add("multiflask_button","form-control");
        calculateButton.value = "Calculate";
        form.appendChild(calculateButton);
        flaskCount.addEventListener("input", (event) => {
            if (isNaN(flaskCount.value) || !Number.isInteger(flaskCount.value)) {
                flaskCount.value = flaskCount.value.replace(/\D/g, '');
            }
        }, false);
        flaskCount.addEventListener("keydown", (event) => {
            if(event.key === "Enter") {
                calculateButton.click();
            }
        }, false);
        calculateButton.addEventListener("click", async () => {
            if(flaskCount.value == 0) flaskCount.value = 1;
            let multiflaskChances = calculateMultiflaskChances(colorChances, flaskCount.value);
            let hasAlts = colorChances.normalChance != colorChances.specificNormalChance;
            stats.innerHTML = formatFlaskStats(multiflaskChances, colorName, isAlt);
        }, false);
        stats.innerHTML = formatFlaskStats(colorChances, colorName, isAlt);
        return container;
    }

    //workaround to prevent auto-focus on mobile
    function enableFlaskBoxes() {
        let boxes = document.querySelectorAll(".multiflask_count");
        boxes.forEach(box => {
            box.disabled = false;
        })
    }

    function miniStats(colorChances, isAlt=false) {
        let miniStats = document.createElement("span");
        miniStats.classList.add("flask_ministats");
        miniStats.innerHTML = `(${(colorChances.normalChance*100).toFixed(2)}% | ✨${(colorChances.carbonatedChance*100).toFixed(2)}%)`
        return miniStats;
    }

    function formatFlaskStats(colorChances, colorName, isAlt=false) {
        let hasAlts = colorChances.normalChance != colorChances.specificNormalChance;
        if(isAlt) {
            return `Normal Flasks: <b>${(colorChances.normalChance*100).toFixed(2)}%</b> ${hasAlts?`(any ${colorName}) |  <b>${(colorChances.specificNormalChance*100).toFixed(2)}%</b> (this version)`:""}<br>
    ✨Carbonated Flasks: <b>${(colorChances.carbonatedChance*100).toFixed(2)}%</b> (base ${colorName}) | <b>${(0).toFixed(2)}%</b> (this version)`
        }
        else {
            return `Normal Flasks: <b>${(colorChances.normalChance*100).toFixed(2)}%</b> ${hasAlts?`(any ${colorName}) |  <b>${(colorChances.specificNormalChance*100).toFixed(2)}%</b> (this version)`:""}<br>
    ✨Carbonated Flasks: <b>${(colorChances.carbonatedChance*100).toFixed(2)}%</b>`
        }
    }

    function formatColorTotals(totals) {
        return `There are <b>${totals.normal}</b> possible results for normal flasks and <b>${totals.carbonated}</b> possible results for ✨carbonated flasks.`
    }

    //count the colors and update data, return if a new color was found
    async function parseColors(content, species) {
        let colorData = JSON.parse(await GM.getValue("colorData", "{}"))
        if(!colorData[species]) {
            colorData[species] = {};
        }
        let newColors = false;
        let colorElements = content.querySelectorAll('.flex-column.small-gap');
        colorElements.forEach(element => {
            let colorName = element.getElementsByTagName("span")[0].innerText.trim();
            if(element.innerHTML.includes("https://grundoscafe.b-cdn.net/items/100012.gif")) {
                if(colorData[species] && colorData[species][colorName]==1) {
                    newColors = true; // there is an alt icon for a color we don't know the alts for
                }
            }
            if(!colorData[species][colorName]) {
                newColors = true; // this is a color we did not know about before
                colorData[species][colorName] = 1;
            }
        })
        await GM.setValue("colorData", JSON.stringify(colorData));
        return newColors;
    }

    async function parseAlts(content, species) {
        let colorData = JSON.parse(await GM.getValue("colorData", "{}"))
        if(!colorData[species]) {
            colorData[species] = {};
        }
        let colorElements = content.querySelectorAll('.flex-column.small-gap');
        let totals = {};
        let newColors = false;
        colorElements.forEach(element => {
            let colorName = element.getElementsByTagName("span")[0].innerText.trim();
            let imgSrc = element.getElementsByTagName("img")[0].src;
            if(colorName == "Royal" || colorName == "Usuki" || colorName == "Quiguki") {
                if(imgSrc.includes("boy")) {
                    colorName = colorName + "boy";
                }
                else if(imgSrc.includes("girl")) {
                    colorName = colorName + "girl";
                }
            }
            totals[colorName] = (totals[colorName] || 0) + 1;

        })
        for (const color in totals) {
            let newTotal = totals[color] + 1;
            if(colorData[species] && colorData[species][color] != newTotal) {
                newColors = true; // this is a different total of alts than we had before
                colorData[species][color] = newTotal;
            }
        }
        await GM.setValue("colorData", JSON.stringify(colorData));
        return newColors;
    }

    async function attachMiniStats(elements, species) {
        for(let element of elements) {
            let colorName = element.getElementsByTagName("span")[0].innerText.trim();
            let imgSrc = element.getElementsByTagName("img")[0].src;
            let isAlt = imgSrc.includes("alt") || imgSrc.includes("classic")
            if(colorName == "Royal" || colorName == "Usuki" || colorName == "Quiguki") {
                if(imgSrc.includes("boy")) {
                    colorName = colorName + "boy";
                }
                else if(imgSrc.includes("girl")) {
                    colorName = colorName + "girl";
                }
            }
            let colorChances = await calculateColorChances(species, colorName)
            element.querySelector("span").after(miniStats(colorChances,isAlt));
        }
    }

    // thank you twiggies!
    async function fetchPage(url) {
        try {
            const response = await fetch(url);
            if(response.ok) {
                const html = await response.text();
                const node = await new DOMParser().parseFromString(html, "text/html");
                // show RE if the fetched page contains a RE
                if (node.getElementById('page_event').innerHTML.trim() != '') {
                    document.getElementById('page_event').innerHTML += node.getElementById('page_event').innerHTML
                }
                return node;
            } else {
                throw new Error(`${response.status} ${response.statusText}`);
            }
        }
        catch(err) {
            console.log(`Encountered error fetching url ${url}:`)
            console.log(err)
        }
    }

    async function fetchColors(species) {
        const colorPage = await fetchPage(`https://www.grundos.cafe/rainbowpool/neopetcolours/?species=${species}`);
        return await parseColors(colorPage, species);
    }

    async function fetchAlts(species) {
        const altPage = await fetchPage(`https://www.grundos.cafe/rainbowpool/neopetcolours/?species=${species}&altsonly=true`);
        return await parseAlts(altPage, species);
    }

    if(document.querySelectorAll(".errorpage").length==0)
    {
        if(urlParam("species")) {
            let species = urlParam("species");
            // for all color/all alt color pages
            if(!urlParam("colour")) {
                let isAlts = urlParam("altsonly")=="true";
                let newColors = false;
                let colorElements = document.querySelectorAll('.flex-column.small-gap');
                if(!isAlts) {
                    newColors = await parseColors(document, species);
                    if(newColors) await fetchAlts(species);
                } else {
                    newColors = await parseAlts(document, species);
                    if(newColors) await fetchColors(species)
                }
                let colorData = JSON.parse(await GM.getValue("colorData", "{}"));
                let flaskTotals = document.createElement("p");
                let totals = await countColors(species);
                flaskTotals.innerHTML = formatColorTotals(totals);
                querySelectorIncludesText("strong","Colours")[0].after(flaskTotals);
                await attachMiniStats(colorElements, species);
            }
            // for specific color pages
            else {
                let colorData = JSON.parse(await GM.getValue("colorData", "{}"))
                if(!colorData[species]) {
                    await fetchColors(species);
                    await fetchAlts(species);
                    colorData = JSON.parse(await GM.getValue("colorData", "{}"))
                }
                if(colorData[species]) {
                    let color = urlParam("colour");
                    let capsSpecies = species=="jubjub"?"JubJub":capitalizeFirstLetter(species);
                    let capsColor = capitalizeFirstLetter(color);
                    capsColor = capsColor.split("_")[0]
                    if(!colorData[species][capsColor]) {
                        await fetchColors(species);
                        await fetchAlts(species);
                        colorData = JSON.parse(await GM.getValue("colorData", "{}"))
                    }
                    let colorChances = await calculateColorChances(species, capsColor)
                    if(!color.includes("alt") && !color.includes("classic")) {
                        let altHeaders = querySelectorIncludesText("strong",`Alternate`);
                        let classicHeaders = querySelectorIncludesText("strong",`Classic`);
                        let allAlts = [...altHeaders, ...classicHeaders];

                        if(colorData[species][capsColor] != allAlts.length+1) {
                            await fetchColors(species);
                            await fetchAlts(species);
                            colorChances = await calculateColorChances(species, capsColor)
                        }

                        querySelectorIncludesText("strong",`${capsColor} ${capsSpecies}`)[0].after(colorStats(colorChances,capsColor,false));

                        allAlts.forEach(header => {
                            header.after(colorStats(colorChances,capsColor,true));
                        })
                    }
                    else {
                        querySelectorIncludesText("strong",capsSpecies)[0].after(colorStats(colorChances,capsColor,true));
                    }

                }
                //workaround to prevent auto-focus on mobile
                setTimeout(() => {
                    enableFlaskBoxes();
                }, 300)
            }
        }
        // color page of all species
        else if(urlParam("colour")) {
            let colorData = JSON.parse(await GM.getValue("colorData", "{}"))
            let color = urlParam("colour");
            let capsColor = capitalizeFirstLetter(color);
            let speciesElements = document.querySelectorAll('.flex-column.small-gap');
            let missingData = false;
            for(let element of speciesElements) {
                let species = element.getElementsByTagName("span")[0].innerText.trim().toLowerCase();
                if(colorData[species] && colorData[species][capsColor]) {
                    let colorChances = await calculateColorChances(species, capsColor)
                    element.querySelector("span").after(miniStats(colorChances));
                }
                else {
                    missingData = true;
                    let noData = document.createElement("span");
                    noData.innerHTML = `(???%)`
                    element.querySelector("span").after(noData);
                }
            }
            if(missingData) {
                let warning = document.createElement("p");
                warning.innerHTML = `Colour data for some of these species is missing or out of date. Please visit their pages to see their flask chances.`
                querySelectorIncludesText("strong","Available")[0].after(warning);
            }
        }
    }
})();