IQRPG Labyrinth Companion

QoL enhancement for IQRPG Labyrinth

// ==UserScript==
// @name         IQRPG Labyrinth Companion
// @namespace    https://www.iqrpg.com/
// @version      1.1.0
// @author       Tempest
// @description  QoL enhancement for IQRPG Labyrinth
// @homepage     https://slyboots.studio/iqrpg-labyrinth-companion/
// @source       https://github.com/SlybootsStudio/iqrpg-labyrinth-companion
// @match        https://*.iqrpg.com/*
// @require      http://code.jquery.com/jquery-latest.js
// @license      unlicense
// @grant        none
// ==/UserScript==

/* global $ */


const SHOW_ROOM_NUMBER = 1;
const SHOW_ROOM_TIER = 1;
const SHOW_ROOM_CHANCE = 1;
const SHOW_ROOM_ESTIMATE = 1;
const SHOW_ROOM_TOTAL = 1;

const MODIFY_NAVIGATION = 1; // 0 - Don't modify nav button, 1 - modify nav button (default)


const NAVITEM_LABY = 7; // Which nav item to style
const RENDER_DELAY = 200; // milliseconds after view loads to render companion
const SKILL_BOX_INDEX = 3; // Will be 2, or 3, depending on Land status.

const MAX_ACTIONS = 100; // This may change in the future if the developer allows an increase.
// If that happens, we'll want to read this from the payload data, and not hard code it.

const CACHE_LAB = "cache_lab"; // Cache for all the
const CACHE_LAB_NAV = "cache_lab_nav";

//-----------------------------------------------------------------------
//-----------------------------------------------------------------------
//
// CACHE
// We are caching the daily labyrinth,
// which is updated each time to labyrinth page is visited.
// The cached data is used when the labyrinth is being run.
//
function writeCache( key, data ) {
  localStorage[key] = JSON.stringify(data);
}

function readCache( key ) {
  return JSON.parse(localStorage[key] || null) || localStorage[key];
}

//-----------------------------------------------------------------------
//-----------------------------------------------------------------------

function renderLaby(data, skills) {

    // Create 3rd column
    let wrapper = $('.two-columns');
    let col3 = $('.two-columns__column', wrapper).eq(1).clone();
    $(wrapper).append(col3);

    // Cache data
    data = JSON.parse(data);
    writeCache(CACHE_LAB, data);

    // On the Laby page, 'currentRoom' refers to the completed rooms.
    // On Laby Run, currentRoom is the target room you're TRYING to pass.
    data.currentRoom += 1;

    // Render table
    renderLabyTable(data, col3, skills);
    writeNavCache(data.turns, data.maxTurns, data.rewardObtained);
}

function renderLabyFromCache(data, skills) {

    let col = $('.labytable');

    // Check to make sure a Laby table doesn't currently exist.
    // If it does, we'll update it.
    // If it doesn't, we'll create one.
    if( !col.length ) {
      let wrapper = $('.main-game-section');
      $(wrapper).css('position', 'relative');
      col = $('.main-section__body', wrapper).clone();
      $(col).css('position', 'absolute');
      $(col).css('top', '30px');
      $(col).css('right', '0');
      $(col).addClass('labytable');
      $(wrapper).append(col);
    }

    data = JSON.parse(data);
    let cached = readCache(CACHE_LAB);

    // Use the cached data for the Laby table, but update the `currentRoom` based
    // on our most up to date information about player progress.
    cached.currentRoom = data?.data?.currentRoom || 0;
    renderLabyTable(cached, col, skills);

    writeNavCache(data.data.turns, data.data.maxTurns, false);
}

/**
 * Lookup a skill name by ID.
 *
 * Labyrinth rooms use an ID to reference which
 * skill is used.
 *
 * @param {Number} id
 *
 * @return {String}
 */
function getSkillNameById(id) {

    let name = "";

    switch(id) {
        case 1: name = "Battling"; break;
        case 2: name = "Dungeon"; break;
        case 3: name = "Mining"; break;
        case 4: name = "Wood"; break;
        case 5: name = "Quarry"; break;
        case 6: name = "Rune"; break;
        case 7: name = "Jewelry"; break;
        case 8: name = "Alchemy"; break;
    }

    return name;
}

/**
 * Lookup a skill name by ID.
 *
 * Labyrinth rooms use an ID to reference which
 * skill is used.
 *
 * @param {Number} roomId
 * @param {Number} currentRoomId
 *
 * @return {String} style
 */
function getStyleByRoomState(roomId, currentRoomId) {

    let style = "";

    if(currentRoomId == roomId ) {
        // Active  Rooms are Yellow Background
        // with Black Text
        style = "background-color: #cc0; color: #000;";
    }

    // Incomplete Rooms are Red Background
    else if(currentRoomId < roomId ) {
        style = "background-color: #600;";
    }

    // Completed Rooms are Green Background
    else if(currentRoomId > roomId ) {
     style = "background-color: #006400;";
    }

    return style;
}

/**
 * Get the percent chance you have to progress
 * through a room, based on the tier of the room
 * and skill level.
 *
 * @param {Number} tier
 * @param {Number} skill
 *
 * @return {Float} chance
 */
function getChanceBySkill(tier, skill) {

    let base = 1;

    switch(tier) {
        case 1: base = 700; break;
        case 2: base = 1000; break;
        case 3: base = 1400; break;
        case 4: base = 1900; break;
        case 5: base = 2500; break;
    }

    let chance = 1000 / base * skill;
    chance = parseFloat(chance / 10).toFixed(2);

    return chance;
}

/**
 * Render the labyrinth table.
 *
 * @param {Object} data
 * @param {domElement} ele
 * @param {Array} skills
 */
function renderLabyTable(data, ele, skills) {

    let remaining = data.maxTurns;

    const trStyle = 'border:1px solid black;';
    const tdStyle = 'padding:3px;border-top:1px solid black;';

    const showNumber = SHOW_ROOM_NUMBER === 1 ? "" : "display:none;";
    const showTier = SHOW_ROOM_TIER === 1 ? "" : "display:none;";
    const showChance = SHOW_ROOM_CHANCE === 1 ? "" : "display:none;";
    const showEstimate = SHOW_ROOM_ESTIMATE === 1 ? "" : "display:none;";
    const showTotal = SHOW_ROOM_TOTAL === 1 ? "" : "display:none;";

    let html = '';
    html += '<div style="width:100%;">'
    html += '<table style="border-spacing:0;float:right;">';
    html += `<tr style='${trStyle}'>`;
    html += `<td style='${tdStyle}'>Room</td>`;
    html += `<td style='${tdStyle}${showTotal}'>(T)Skill</td>`;
    html += `<td style='${tdStyle}${showChance}'>Chance</td>`;
    html += `<td style='${tdStyle}${showEstimate}'>Est</td>`;
    html += `<td style='${tdStyle}${showTotal}'>Total</td>`;

    data.data.map( (room, i) => {

        let skill = room[0];
        let tier = room[1];
        const style = getStyleByRoomState(i+1, data.currentRoom);
        const skillName = getSkillNameById(skill);
        const chance = getChanceBySkill(tier, skills[skill-1]);
        const estimated = Math.round(100/chance);
        remaining -= estimated;


        html += `<tr style='${style};${trStyle}'>`;
        html += `<td style='${tdStyle}'>${i+1}</td>`;
        html += `<td style='${tdStyle}${showTotal}'>(${tier})${skillName}</td>`;
        html += `<td style='${tdStyle}${showChance}'>${chance}%</td>`;
        html += `<td style='${tdStyle}${showEstimate}'>${estimated}</td>`;
        html += `<td style='${tdStyle}${showTotal}'>${100 - remaining}</td>`;

        html += `</tr>`;
    });

    html += "</table></div>";

    ele.html(html);
}

/**
 * Update the Labyrinth button in the navigation to match the state of progress.
 *
 * @param {Object} data
 * @param {domElement} ele
 * @param {Array} skills
 */
function updateNavigation() {

    // Players can disable modification entirely using this setting.
    if(!MODIFY_NAVIGATION) {
        return;
    }

    // This is the payload we should be pulling out from cache
    /*
     * data : {
     *   turns
     *   maxTurns
     *   rewardObtained
     *   date
     * }
     */
    let data = readCache(CACHE_LAB_NAV);


    // We only want to use cached data from today.
    // Set to New York Timezone, which matches server time.
    let date = new Date();
    let _date = date.toLocaleString('en-GB', { timeZone: 'America/New_York' }).split(',')[0];
    if( _date != data?.date) {
        data = undefined;
    }

    //console.log(_date, data?.date);

    let link = $('.nav a').eq(NAVITEM_LABY);

    if(!data) {
        // We lack information until the user clicks on Labyrinth
        $(link).css('font-weight', 'bold');
        $(link).css('color', 'white');
        $(link).css('background-color', 'red');
        $(link).html("Labyrinth [Need Info]");
    } else if (data.turns < data.maxTurns) {
        // User has turns remaining
        $(link).css('color', 'yellow');
        $(link).css('font-weight', 'bold');
        $(link).css('background-color', 'red');
        $(link).html("Labyrinth [Unfinished]");
    } else if(!data.rewardObtained) {
        // Labyrinth is complete, but reward unclaimed
        $(link).css('color', 'red');
        $(link).css('font-weight', 'bold');
        $(link).css('background-color', 'white');
        $(link).html("Labyrinth [CLAIM LOOT]");
    } else if(data.rewardObtained) {
        // Labyrinth is complete, Reward claimed
        $(link).css('color', '#ccc');
        $(link).css('font-weight', 'normal');
        $(link).css('background-color', '');
        $(link).html("Labyrinth [Complete]");
    }
}

function writeNavCache(turns, maxTurns, rewardObtained) {

    let date = new Date();
    const _date = date.toLocaleString('en-GB', { timeZone: 'America/New_York' }).split(',')[0];

    const payload ={
        turns : turns ?? 0,
        maxTurns : maxTurns ?? MAX_ACTIONS,
        rewardObtained : rewardObtained ?? false,
        date : _date
    }

    //console.log("Cache", payload);

    writeCache(CACHE_LAB_NAV, payload);
    updateNavigation();
}

/**
 * Parse the Skill box into an array of your skill levels
 *
 * This works whether or not the box is collapsed.
 *
 * @return {Array} skills
 */
function parseSkills() {

    // New players won't have the 'Land' main-section box,
    // which appears above 'Skills'. We need to check the text
    // To make sure we're in the right box.

    // For new players, this is skills.
    // For land players, this is land.
    let text = $('.main-section').eq(SKILL_BOX_INDEX - 1).text();

    if(!text.includes('Skills')) {
        // For new players, this is center content.
        // For land players, this is skills.
        text = $('.main-section').eq(SKILL_BOX_INDEX).text();
    }

    //
    // The html has been converted into a text string.
    // Now we parse the string into an array.
    text = text.replace('Skills', '');
    text = text.replaceAll('(', '');

    let skills = text.split(')');

    skills = skills.map( skill => {
        // "skillname (level)"
        skill = skill.trim();
        skill = skill.split(' ');
        return skill[1];
    });

    return skills;
}

let loadOnce = false;

//-----------------------------------------------------------------------
//-----------------------------------------------------------------------

let send = window.XMLHttpRequest.prototype.send;

function sendReplacement(data) {
    if(this.onreadystatechange) {
        this._onreadystatechange = this.onreadystatechange;
    }

    this.onreadystatechange = onReadyStateChangeReplacement;
    return send.apply(this, arguments);
}


function onReadyStateChangeReplacement() {

    //
    // This is called anytime there is an action complete, or a view (page) loads.
    // console.log('Response URL', this.responseURL);
    //
    setTimeout( () => {
        //
        // Player Loading the Labyrinth View
        //
        if(this.responseURL.includes("php/misc.php?mod=loadLabyrinth")) {
            // LoadOnce is tracked because this function can be triggered
            // multiple times in a single call.
            if(this.response && !loadOnce) {
              loadOnce = true;
              $('.labytable').remove(); // Remove old table
              let skills = parseSkills(); // Scans skill table
              renderLaby(this.response, skills); // Render a new table.
            }
        }
        //
        // Player is running the Labyrinth
        //  - We're still on the labyrinth page, but the actions are being executed.
        //
        else if(this.responseURL.includes("php/actions/labyrinth.php")) {
            let skills = parseSkills();
            renderLabyFromCache(this.response, skills);
        }
        //
        // Player has claimed the Labyrinth reward
        //
        else if(this.responseURL.includes("misc.php?mod=getLabyrinthRewards")) {
            writeNavCache(MAX_ACTIONS, MAX_ACTIONS, true);
        }
        //
        // We're on another page, elsewhere in IQ
        //
        else {
            // Remove The Labyrinth table.
            $('.labytable').remove();
            loadOnce = false;
        }



    }, RENDER_DELAY );

            //
        // Update the navigation to make sure we're showing the latest information.
        //
        updateNavigation();

    /* Avoid errors in console */
    if(this._onreadystatechange) {
        return this._onreadystatechange.apply(this, arguments);
    } else {
        return this._onreadystatechange;
    }
}

window.XMLHttpRequest.prototype.send = sendReplacement;