Cook Every Fish

Rakky's Talibri Enhancer

// ==UserScript==
// @name         Cook Every Fish
// @description  Rakky's Talibri Enhancer
// @namespace    https://talibri.com/
// @version      0.5.1
// @author       Rakky
// @match        http*://talibri.com/*
// @grant        none
// ==/UserScript==

// This is my first ever script!
// Based on Pendoria+ and PendoriaQoL scripts.
// They helped me a lot.

// Changelog for 0.5.1
// -Bugfix: Now parses correctly when you gather extra items due to equipment bonuses.

// Changelog for 0.5.0
// -Changed the way I name my versions. This is now v0.5.0 instead of v0.0.5.
// -Added data tracking for Gathering. (Crafting data is coming soon.)
// -Now parses the gathering popup for all the information. (Crafting will get this soon too!)
// -Fixed some bad logic in my progress bar code.
// -Limited how often the data updates so it's not so hard on the cpu. holy cow.

$(function() {
    // Change these const variables to suit your needs. #'s = number of actions
    // In the future I'll make a proper UI for this.
    const LOW_ACTIONS = 100;
    const VERY_LOW_ACTIONS = 15;
    const LOW_ACTIONS_AUDIO_WARNING = 15;
    const LOW_ACTIONS_SOUND = new Audio("https://soundbible.com/grab.php?id=1424&type=mp3");

    function incrementActions(){
        let actions = localStorage.getItem("rakkyAction");
        actions++;
        localStorage.setItem("rakkyAction", actions);
    }
    function updateActionsCounter(){
        $('td[class="row-1 column-1"]').text("Actions: " + localStorage.getItem("rakkyAction"));
    }
    function resetActions(){
        localStorage.setItem("rakkyAction", 0);
        let data = {
            "actions": {
                "total" : 0,
                "successful": 0
            },
            "material": undefined,
            "quantity": {
                "inventory": 0,
                "total": 0,
                "last": 0
            },
            "mastery": {
                "current": 0,
                "toNext": 0
            },
            "experience": {
                "total": 0,
                "last": 0
            }
        };
        localStorage.setItem("gatheringData", JSON.stringify(data));
        updateActionsCounter();
    }

    function parseMatsQuantity(){
        let mats = [];
        let elements = $('div[class~=ingredients]:not([style*="display: none"]) .input-group button span');
        elements.each( function(index, obj){
            mats.push($(this).text());
        });
        return mats;
    }
    function updateMatsQuantity(mats){
        localStorage.setItem("rakkyMats", mats.toString());
    }
    function getMatsQuantity(){
        let mats = localStorage.getItem("rakkyMats").split(",");
        return mats;
    }

    function updateMatsUsedPerAction(mats){
        localStorage.setItem("rakkyMatsUsedPerAction", mats.toString());
    }
    function getMatsUsedPerAction(){
        let mats = localStorage.getItem("rakkyMatsUsedPerAction").split(",");
        return mats;
    }

    let newHtml = '';
    function formatMatsHtml(mats){
        newHtml = "";
        let matsUsedPerAction = getMatsUsedPerAction();
        for(let i=0; i<mats.length; i++){
            let remainingActions = Math.floor(mats[i]/matsUsedPerAction[i]);
            newHtml = newHtml + '<span style="color: ';
            if(remainingActions < LOW_ACTIONS && remainingActions > VERY_LOW_ACTIONS){
                newHtml = newHtml + 'orange">';
            }else if(remainingActions <= VERY_LOW_ACTIONS){
                newHtml = newHtml + 'red">';
            }else{
                newHtml = newHtml + 'green">';
            }
            if(mats[i] === 0){remainingActions = 0;}
            newHtml = newHtml + mats[i] + " (" + remainingActions + ")" + "</span>";

            if(i < mats.length - 1){
                newHtml = newHtml + " / ";
            }
        }
    }

    // If user doesn't have craft page open, then we just have to estimate it but subtracting
    // mat costs from the last seen mat numbers.
    // May not be accurate if market orders have gone through.
    function subtractMatsUsedInAction(){
        let mats = getMatsQuantity();
        let matsUsedPerAction = getMatsUsedPerAction();
        for(let i = 0; i < mats.length; i++){
            mats[i] -= matsUsedPerAction[i];
        }
        updateMatsQuantity(mats);
    }

    function getActionsRemaining(){
        let mats = getMatsQuantity();
        let matsUsedPerAction = getMatsUsedPerAction();
        let actionsRemaining = Number.MAX_VALUE;
        for(let i = 0; i < mats.length; i++){
            let remaining = mats[i] / matsUsedPerAction[i];
            if ( remaining < actionsRemaining){
                actionsRemaining = remaining;
            }
        }
        return Math.floor(actionsRemaining);
    }
    function pingIfLow(){
        let mats = localStorage.getItem("rakkyMats").split(",");
        let actionsRemaining = getActionsRemaining();

        if(actionsRemaining < LOW_ACTIONS_AUDIO_WARNING){
            LOW_ACTIONS_SOUND.play();
        }
    }

    // Create the html for displaying the actions, mats, etc.
    // Still need to work on Crafting.
    function createDisplay(){
        let html = "";
        html += '<div id="user-stat-actions" style="flex-wrap: wrap; position: relative; display: flex; width: 100%; height: 65px;">';
        html += '<div id="progressBarContainer" style="width: 50%; position: absolute; background-color: lightgray; display: flex; height: 33%;">';
        html += '<div id="progressBar" style="background-color: lightgreen; flex-direction: column; width: 0%;"></div></div>';
        html += '<div style="padding-left: 5px; padding-top: 2px; flex-shrink: 0; position: relative; width: 100%; height: 33%;">';
        html += '<table style="table-layout: fixed; width: 100%";>';
        html += '<tr><td class="row-1 column-1" style="width: 25%"></td><td class="row-1 column-2" style=""></td><td class="row-1 column-3" style="width: 25%"></td><td class="row-1 column-4" style="width: 25%"></td></tr>';
        html += '<tr><td class="row-2 column-1" colspan="2" style="width: 25%"></td><td class="row-2 column-2" style=""></td><td class="row-2 column-3" style="width: 25%"></td></tr>';
        html += '<tr><td class="row-3 column-1" style=width: 25%""></td><td class="row-3 column-2" style=""></td><td class="row-3 column-3" style="width: 25%"></td><td class="row-3 column-4" style="width: 25%"></td></tr>';
        html += '</table>';
        html += '</div></div>';
        $('.user-stat-line').append(html);
    }
    function updateMatsCounter(){
        formatMatsHtml(getMatsQuantity());
        $('td[class="row-2 column-1"]').html(newHtml);
        $('td[class="row-1 column-2"]').html("");
        $('td[class="row-1 column-3"]').html("");
        $('td[class="row-1 column-4"]').html("");
        $('td[class="row-2 column-2"]').html("");
        $('td[class="row-2 column-3"]').html("");
        $('td[class="row-3 column-1"]').html("");
        $('td[class="row-3 column-2"]').html("");
        $('td[class="row-3 column-3"]').html("");
        $('td[class="row-3 column-4"]').html("");
    }

    // These are old functions I copy pasted from an old spreadsheet I made. I'll fix them up later.
    function convertTo24_60_60_format(oldFormat){
        var time = [0,0,0,0];
        time[3] = oldFormat[3] % 60;
        oldFormat[2] += Math.floor(oldFormat[3]/60);
        time[2] = oldFormat[2] % 60;
        oldFormat[1] += Math.floor(oldFormat[2]/60);
        time[1] = oldFormat[1] % 24;
        oldFormat[0] += Math.floor(oldFormat[1]/24);
        time[0] = oldFormat[0];

        return time;
    }
    function toTimeString(time){
        var timeString = "";
        var units = ["d", "h", "m", "s"];
        for(var i = 0; i < 4; i++){
            if(time[i] !== 0){
                timeString = timeString + time[i] + units[i] + " ";
            }
        }

        return timeString;
    }
    function convertToDuration(days, hours, minutes, seconds){
        var time = [];
        time[0] = Math.floor(days);
        hours += (days - time[0])*24;
        time[1] = Math.floor(hours);
        minutes += (hours - time[1])*60;
        time[2] = Math.floor(minutes);
        seconds += (minutes - time[2])*60;
        time[3] = Math.floor(seconds);

        time = convertTo24_60_60_format(time);
        return toTimeString(time);
    }

    function updateGatheringCounter(){
        let data = getGatheringData();

        $('td[class="row-1 column-1"]').html("<strong>Actions: </strong>" + data.actions.total);
        let successChance = 0;
        if(data.actions.total > 0){ successChance = data.actions.successful/data.actions.total*100; }
        $('td[class="row-1 column-2"]').html("<strong>Successful: </strong>" + data.actions.successful + " (" + successChance.toLocaleString(undefined, {maximumSignificantDigits: 4}) + "%)");
        let material = data.material;
        if(!material){ material = "Nothing"; }
        $('td[class="row-2 column-1"]').html("<strong>Harvesting: </strong>" + material);
        $('td[class="row-3 column-1"]').html("<strong>In Inventory: </strong>" + data.quantity.inventory.toLocaleString() + " (+" + data.quantity.last.toLocaleString() + ")");
        let average = 0;
        if(data.actions.total > 0){ average = data.quantity.total/data.actions.total; }
        $('td[class="row-3 column-2"]').html("<strong>Average: </strong>" + average.toLocaleString(undefined, {maximumSignificantDigits: 4}));
        $('td[class="row-1 column-3"]').html("<strong>Mastery: </strong>" + data.mastery.current.toLocaleString() + " / " + data.mastery.toNext.toLocaleString());
        let actionsToNextMastery = data.mastery.toNext - data.mastery.current;
        let timeToNextMastery = "Forever";
        if(successChance > 0){
            timeToNextMastery = convertToDuration(0,0,0,actionsToNextMastery * 5 / (successChance/100));
        }
        $('td[class="row-1 column-4"]').html("<strong>To Next: </strong>" + timeToNextMastery);
        $('td[class="row-2 column-2"]').html("<strong>Exp: </strong>" + data.experience.total.toLocaleString() + " (+" + data.experience.last.toLocaleString() + ")");
        let avgExp = 0;
        if(data.actions.total > 0){ avgExp = data.experience.total/data.actions.total; }
        $('td[class="row-2 column-3"]').html("<strong>Average: </strong>" + avgExp.toLocaleString(undefined, {maximumSignificantDigits: 4}));
    }

    let inProgress = false;
    function updateProgressBar(){
        let timestamp = Date.now();
        let start = parseInt(localStorage.getItem('rakkyStartTime'));
        let progress = timestamp - start;
        $('#progressBar')[0].style.width = progress / 5000 * 100 + '%';
        if(progress < 5000){
            setTimeout(function(){ window.requestAnimationFrame(updateProgressBar); }, 1000/30); // updates at 30 fps
        }else{
            $('#progressBar')[0].style.width = '0%';
            inProgress = false;
        }
    }
    function updateDisplay(){
        let start = parseInt(localStorage.getItem('rakkyStartTime'));
        if(!inProgress && (Date.now() - start) < 5000){
            window.requestAnimationFrame(updateProgressBar);
        }
        if(localStorage.getItem("actionType") == "Gathering"){
            updateGatheringCounter();
        }else{
            updateActionsCounter();
            updateMatsCounter();
        }
        setTimeout(function(){ window.requestAnimationFrame(updateDisplay); }, 1000/5); // updates at 5 fps
    }
    function parseMatCosts(divIngredients){
        let numberOfIngredients = divIngredients.children('div.input-group').length;

        let matCosts = [];
        divIngredients.children('div.input-group').each( function(index, obj){
            // note: $(this) is referring to a different object than the one above while in this scope.
            let spanText = $(this).text().split(" ");
            matCosts.push(parseInt(spanText[0]));
        });
        return matCosts;
    }

    function removeNavBarIcons(){
        $('.user-stat-line').children().remove();
    }

    function startProgressBar(){
        localStorage.setItem("rakkyStartTime", Date.now());
        window.requestAnimationFrame(updateProgressBar);
    }

    // Parsing the Gathering popup
    function getGatheringData(){
        let data = localStorage.getItem("gatheringData");
        if(data){
            data = JSON.parse(data);
            data.actions.total = parseInt(data.actions.total);
            data.actions.successful = parseInt(data.actions.successful);
            data.quantity.total = parseInt(data.quantity.total);
            data.quantity.last = parseInt(data.quantity.last);
            data.mastery.current = parseInt(data.mastery.current);
            data.mastery.toNext = parseInt(data.mastery.toNext);
            data.experience.total = parseInt(data.experience.total);
            data.experience.last = parseInt(data.experience.last);
        }else{
            data = undefined;
        }
        return data;
    }
    function parseForMaterialName(text){
        let spanStart = text.indexOf('<span', text.indexOf('successfully gathered'));
        let spanEnd = text.indexOf('>', spanStart);
        let endSpanStart = text.indexOf('<', spanEnd);
        let materialType = text.substring(spanEnd + 1, endSpanStart);
        return materialType;
    }
    function parseForMaterialNameOnFailure(text){
        let searchTerm = 'You failed to gather ';
        let searchTermIndex = text.indexOf(searchTerm);

        if(searchTermIndex < 0){ return undefined; }else{
            let start = searchTermIndex + searchTerm.length;
            let end = text.indexOf('.', start);
            let amount = text.substring(start, end);
            return amount;
        }
    }
    function parseForQuantityGathered(text){
        let searchTerm = 'successfully gathered ';
        let searchTermIndex = text.indexOf(searchTerm);

        if(searchTermIndex < 0){ return undefined; }else{
            let start = searchTermIndex + searchTerm.length;
            let end = text.indexOf(' <', start);
            let amount = text.substring(start, end);
            return amount;
        }
    }
    function parseForQuantityTotal(text){
        let searchTerm = 'You now have ';
        let searchTermIndex = text.indexOf(searchTerm);

        if(searchTermIndex < 0){ return undefined; }else{
            let start = searchTermIndex + searchTerm.length;
            let end = text.indexOf('.', start);
            let amount = text.substring(start, end);
            return amount;
        }
    }
    function parseForMastery(text){
        let searchTerm = 'Mastery Progress: ';
        let searchTermIndex = text.indexOf(searchTerm);

        if(searchTermIndex < 0){ return undefined; }else{
            let start = searchTermIndex + searchTerm.length;
            let end = text.indexOf('\\n', start);
            let masteryText = text.substring(start, end);
            let mastery = masteryText.split('/');
            return mastery;
        }
    }
    function parseForExperience(text){
        let searchTerm = 'You gained ';
        let searchTermIndex = text.indexOf(searchTerm, text.indexOf('successfully gathered'));

        if(searchTermIndex < 0){ return undefined; }else{
            let start = searchTermIndex + searchTerm.length;
            let end = text.indexOf(' experience!', start);
            let amountText = text.substring(start, end);
            let amountArray = amountText.split(',');
            amountText = '';
            for(let i = 0; i < amountArray.length; i++){
                amountText = amountText + amountArray[i];
            }
            let amount = amountText;
            return amount;
        }
    }
    function parseGatheringPopup(responseText){
        let data = {
            "isSuccessful": undefined,
            "mastery": {
                "current": undefined,
                "toNext": undefined
            },
            "experience": undefined,
            "material": undefined,
            "quantity": {
                "total": undefined,
                "last": undefined
            }
        };

        let text = responseText;

        let mastery = parseForMastery(text);
        if(mastery){
            data.mastery.current = parseInt(mastery[0]);
            data.mastery.toNext = parseInt(mastery[1]);
        }

        let experience= parseForExperience(text);
        if(experience){ data.experience = parseInt(experience); }

        if(text.indexOf("failed") > 0){
            data.isSuccessful = false;

            let material = parseForMaterialNameOnFailure(text);
            if(material){ data.material = material; }
        }else{
            data.isSuccessful = true;

            let material = parseForMaterialName(text);
            if(material){ data.material = material; }

            let total = parseForQuantityTotal(text);
            if(total){ data.quantity.total = parseInt(total); }

            let last = parseForQuantityGathered(text);
            if(last){ data.quantity.last = parseInt(last); }
        }
        return data;
    }
    function updateGatheringData(popup){
        /* popup = {
            "isSuccessful" : undefined,
            "mastery": {
                "current": undefined,
                "toNext": undefined
            },
            "experience": undefined,
            "material": undefined,
            "quantity": {
                "total": undefined,
                "last": undefined
            }
            };*/
        /* data = {
            "actions": {
                "total" : 0,
                "successful": 0
            },
            "material": undefined,
            "quantity": {
                "inventory": 0,
                "total": 0,
                "last": 0
            },
            "mastery": {
                "current": 0,
                "toNext": 0
            },
            "experience": {
                "total": 0,
                "last": 0
            }; */
        let data = getGatheringData();

        data.actions.total++;
        if(popup.isSuccessful){
            data.actions.successful++;
            data.quantity.inventory = popup.quantity.total;
            data.quantity.total = data.quantity.total + popup.quantity.last;
            data.quantity.last = popup.quantity.last;
        }
        data.material = popup.material;
        data.mastery.current = popup.mastery.current;
        data.mastery.toNext = popup.mastery.toNext;
        data.experience.total = data.experience.total + popup.experience;
        data.experience.last = popup.experience;

        localStorage.setItem("gatheringData", JSON.stringify(data));
    }

    function init(){
        let actions = localStorage.getItem("rakkyAction");
        let activeMats = localStorage.getItem("rakkyMats");
        let matsUsedPerAction = localStorage.getItem("rakkyMatsUsedPerAction");
        let startTime = localStorage.getItem("rakkyStartTime");
        let gatheringData = localStorage.getItem("gatheringData");
        let actionType = localStorage.getItem("actionType");

        if(!actions){ localStorage.setItem("rakkyAction", 0); }
        if(!activeMats){ localStorage.setItem("rakkyMats", 0); }
        if(!matsUsedPerAction){ localStorage.setItem("rakkyMatsUsedPerAction", 1); }
        if(!startTime){ localStorage.setItem("rakkyStartTime", 0); }
        if(!gatheringData){
            let data = {
                "actions": {
                    "total" : 0,
                    "successful": 0
                },
                "material": undefined,
                "quantity": {
                    "inventory": 0,
                    "total": 0,
                    "last": 0
                },
                "mastery": {
                    "current": 0,
                    "toNext": 0
                },
                "experience": {
                    "total": 0,
                    "last": 0
                }
            };
            localStorage.setItem("gatheringData", JSON.stringify(data));
        }

        removeNavBarIcons();
        if($('#user-stat-actions').length === 0){
            createDisplay();
            updateDisplay();
        }

        let selector = 'div[class~=recipe-requirements]';
        $(selector).on("click", 'ul.dropdown-menu', function(){
            let divIngredients = $(this).closest('div[class~=ingredients]');

            localStorage.setItem("actionType", "Crafting");
            updateMatsUsedPerAction(parseMatCosts(divIngredients));
            updateMatsQuantity(parseMatsQuantity());
            updateMatsCounter();
        });

        // When user clicks the actions display
        $(document).on("click", '#user-stat-actions', resetActions);
    }

    function ajaxCraft(){
        localStorage.setItem("actionType", "Crafting");
        startProgressBar();
        incrementActions();
        updateActionsCounter();

        // Estimate mat quantity if not on craft page. Otherwise, use the craft page's mat numbers.
        if($('div[class~=ingredients]:not([style*="display: none"]) .input-group button span').length === 0){
            subtractMatsUsedInAction();
        } else {
            updateMatsQuantity(parseMatsQuantity());
        }
        updateMatsCounter();

        pingIfLow();
    }
    function ajaxGather(e, xhr, settings){
        if(xhr.responseText.indexOf("tooltip") > 0){
            localStorage.setItem("actionType", "Gathering");

            let popup = parseGatheringPopup(xhr.responseText);
            let data = getGatheringData();
            if (popup.material != data.material){
                data = {
                    "actions": {
                        "total" : 0,
                        "successful": 0
                    },
                    "material": undefined,
                    "quantity": {
                        "inventory": 0,
                        "total": 0,
                        "last": 0
                    },
                    "mastery": {
                        "current": 0,
                        "toNext": 0
                    },
                    "experience": {
                        "total": 0,
                        "last": 0
                    }
                };
                localStorage.setItem("gatheringData", JSON.stringify(data));
            }
            startProgressBar();
            updateGatheringData(popup);
        }
    }

    const AJAX_CALLBACKS = {
        '/craft': ajaxCraft,
        '/skills/1/start_gathering.js': ajaxGather, // Mining
        '/skills/2/start_gathering.js': ajaxGather, // Woodcutting
        '/skills/3/start_gathering.js': ajaxGather, // Fishing
        '/skills/4/start_gathering.js': ajaxGather, // Botany
    };

    // ****-Script Begin-****

    // Detecting when a new page is loaded and initializing my UI elements.
    // It's a little bit wonky because apparently Talibri uses
    // something called turbolinks which doesn't work like normal page loads.
    $(document).ready(function(){
        $(window).trigger("load");
    });
    $(window).on("load turbolinks:load", function(){
        init();
        console.log("Cook Every Fish loaded!");
    });

    $(document).ajaxError(function(){
        console.log("Error!");
    });

    // When an action is performed.
    $(document).ajaxComplete(function(e, xhr, settings){
        // Figure out which action is being done, to run the appropriate method.
        if (!settings.url || !(settings.url in AJAX_CALLBACKS)) {
            return;
        }
        AJAX_CALLBACKS[settings.url](e, xhr, settings);
    });

    // Update the display
    window.requestAnimationFrame(updateDisplay);
});