Stonehub

small improvements for idlescape's marketplace

// ==UserScript==
// @name         Stonehub
// @namespace    http://tampermonkey.net/
// @version      1.2.1
// @description  small improvements for idlescape's marketplace
// @author       weld, gamergeo, chrOn0os, godi
// @include      *://*idlescape.com/game*
// @run-at       document-start
// @grant        none
// ==/UserScript==

class Stonehub {

    /**
     * Stonehub is a tampermonkey extension ; its purpose is to add some QoL on the marketplace.
     * This works by hooking the current running socket and make simple call to the server.
     */

    constructor() {

        /**
         * This dictionnary embed all the pair event-methods.
         * You can easily implement new corresponding methods by adding the event as the key, and a reference to the right method.
         */
        this.event_to_action = {
            "get player marketplace items":this.convenients_marketplace_items_action,
            "get player auctions":this.convenients_sell_item_action,
			"update inventory":this.update_inventory_action
        };

        // some macros
        this.extension_id = 'stonehub'
        this.stonehub_version = "V1.2.0";
        this.status_refresh_time = 5000;

        this.socket_latency     = 2000;
        this.auto_refresh_time  = 1000;
        this.auto_refresh_auction_time  = 5000;

        this.sockets = [];

		// Used for canceling order confirmation
        this.itemID = -1;
        this.inventory_item_id = -1;

        this.update_ui_rate = 500;

		// Used for minPrice Storage
        this.raw_item_id = -1;
        this.min_price = -1;

        this.latest_watched_itemID = -1;

        this.NUMBER_ATTEMPT = 1000;
        this.WAITING_TIMEOUT = 50;

        this.status_div;
        this.activated_extensions = {
            'stonehub':false,
            'updateui':false
        };

    }

    message_handler(that, e) {
        /**
         * Here is the message handler.
         * When a new websocket message is emitted from the server
         * the extension hooks it and call the desired methods.
         * Check out this.event_to_action.
         *
         * All the methods are called with a reference to this/that and the datas the game threw.
         * Please keep at least a reference to that when creating a new method.
         */
        let msg = e.data;
        msg = (msg.match(/^[0-9]+(\[.+)$/) || [])[1];
        if(msg != null){
            let msg_parsed = JSON.parse(msg);
            let [r, data] = msg_parsed;

            // if the event is stored in event_to_action, execute the function
            if(r in that.event_to_action) that.event_to_action[r](that, data);
        }
    }

    error_handler(that, e) {
        let alert_msg = "Something goes wrong with Stonehub ! \nError msg: " + e.message + "\nPlease reload the page or contact messenoire / Gamergeo";
        console.log(e);
        alert(alert_msg);
    }

    start() {
        /**
         * Main part of the extension
         */

        // keeping the track of this, since changing the scope of the function modify it.
        var that = this;

        /* src: https://stackoverflow.com/questions/59915987/get-active-websockets-of-a-website-possible */
        /* Handle the current running socket */
        const nativeWebSocket = window.WebSocket;
        window.WebSocket = function(...args){
            const socket = new nativeWebSocket(...args);
            that.sockets.push(socket);
            return socket;
        };

        // wait for loading to complete, then check which ext is activated and call the main handler
        let page_ready = setInterval(() =>{
            if(document.readyState == 'complete'){
                clearInterval(page_ready);
                that.set_status(that);
                that.retrieve_status_div(that)
                try{
                    if(that.sockets.length != 0){
                        //if it triggers the socket, listen to message
                        that.sockets[0].addEventListener('message', (e) => this.message_handler(that, e));
    
                        // display a logo
                        setTimeout(() => {
                            var usersOnlineDiv = document.getElementById("usersOnline");
                            var spantext = document.createElement('span');
                            spantext.setAttribute("style","color:#54FF9F;text-shadow: 1px 1px 10px #39c70d;background-image:url(https://i.ibb.co/6m0vqhc/bg1.gif);");
                            spantext.appendChild(document.createTextNode(" | Stonehub " + that.stonehub_version));
                            usersOnlineDiv.appendChild(spantext);
                        },  that.socket_latency);
                    }
                    else
                        throw new Error('socket not initialized');
                } catch(e) {that.error_handler(that, e);}
            }
            ;
        }, 200);
    }
}

Stonehub.prototype.create_status_div = function(that) {
    /**
     * <div id='stonehub_status'></div>
     */
    const sdiv = document.createElement('div');
    sdiv.id = 'stonehub_status';
    sdiv.style.display = 'none';
    document.body.appendChild(sdiv);
    return document.getElementById('stonehub_status');
}

Stonehub.prototype.set_status = function(that) {
    if(!that.activated_extensions.stonehub){
        that.status_div = that.status_div ?? that.create_status_div(that);
        let ext_status = document.createElement('div');
        ext_status.id = that.extension_id;
        that.status_div.appendChild(ext_status);
    }
}

Stonehub.prototype.retrieve_status_div = function(that) {
    /**
     * Checks inside <div id='stonehub_status'></div> which script is activated
     * and update its state inside this.activated_extensions
     */
    setInterval(() => {    
        that.status_div = that.status_div ?? that.create_status_div(that);
        [...that.status_div.children].forEach(ext =>{
            that.activated_extensions[ext.id] = true;
        });
        //console.log(that.activated_extensions);
    }, that.status_refresh_time);
}


Stonehub.prototype.int_to_commas = function(x) {
    // src https://stackoverflow.com/questions/2901102/how-to-print-a-number-with-commas-as-thousands-separators-in-javascript
    // 10100 into 10,100
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

Stonehub.prototype.commas_to_int = function(s) {
    // 10,100 into 10100
    let result;
    if(typeof s == 'number')
  	    result = s
    else
   	    result = s.replace(/[^\d\.\-]/g, "");
    return parseInt(result);
}

Stonehub.prototype.convenients_marketplace_items_action = function(that, data){

	/**
	 * We need to store the items info for other features
	 */
	that.min_price = data[0].price;
	that.raw_item_id = data[0].itemID;

    /**
     * This method add some convenients and small adjustements to an item page.
     * Current features :
     *      Autorefresh
     */

    // === AUTOREFRESH ==== //
    setTimeout(() => {
        let crafting_table_exists = document.getElementsByClassName('crafting-table marketplace-table');
        if(crafting_table_exists.length != 0)
            that.sockets[0].send('42["get player marketplace items",'+data[0].itemID+']');
    },  that.auto_refresh_time);
}

Stonehub.prototype.show_popup_sell_item = function(that, order_data) {

    let id = order_data.id;
    let itemID = order_data.itemID;
    let inventory_item_id = order_data.inventory_item_id;
    let initial_price = order_data.price;
    let initial_amount =  order_data.stackSize;

    /**
     * This method implements a resell feature
     * Shows when you click on stone button
     */
    let modify_auction_popup_html = `<div role="presentation" class="MuiDialog-root sell-item-dialog" style="position: fixed; z-index: 1300; right: 0px; bottom: 0px; top: 0px; left: 0px;">
                                    <div class="MuiBackdrop-root" aria-hidden="true" style="opacity: 1; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;"></div>
                                    <div tabindex="0" data-test="sentinelStart"></div>
                                    <div class="MuiDialog-container MuiDialog-scrollPaper" role="none presentation" tabindex="-1" style="opacity: 1; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;">
                                    <div class="MuiPaper-root MuiDialog-paper MuiDialog-paperScrollPaper MuiDialog-paperWidthSm MuiPaper-elevation24 MuiPaper-rounded" role="dialog">
                                        <div class="MuiDialogTitle-root">
                                            <h5 class="MuiTypography-root MuiTypography-h6">Modify Auction</h5>
                                        </div>
                                        <div class="MuiDialogContent-root">
                                            <p class="MuiTypography-root MuiDialogContentText-root MuiTypography-body1 MuiTypography-colorTextSecondary">How many do you want to sell?</p>
                                            <input id="amount" type="text" value="`+initial_amount+`">
                                            <p class="MuiTypography-root MuiDialogContentText-root MuiTypography-body1 MuiTypography-colorTextSecondary">Price per item you wish to sell<br><span id="lowest-price">Current lowest price on market: <span id="min_price` + itemID + `">0</span>
											<img src="/images/gold_coin.png" alt="Gold coins" class="icon10"></span></p>
                                            <p class="MuiTypography-root MuiDialogContentText-root textography-body1 MuiTypography-colorTextSecondary"></p>
                                            <input id="price" type="text" value="`+ initial_price + `">
                                            <div id='min_price_button_nok' variant="contained" color="secondary" class="item-dialogue-button idlescape-button idlescape-button-red">Adapt price</div>
                                            <div id='min_price_button_ok' style="display:none" variant="contained" color="secondary" class="item-dialogue-button idlescape-button idlescape-button-red">Adapt price</div>
                                            <p class="MuiTypography-root MuiDialogContentText-root MuiTypography-body1 MuiTypography-colorTextSecondary">You will receive: <span id='benefits'>0</span> <img src="/images/gold_coin.png" alt="" class="icon16"> <br>After the fee of : <span id='fees'>0</span>  <img src="/images/gold_coin.png" alt="" class="icon16"></p>
                                        </div>
                                        <div class="MuiDialogActions-root MuiDialogActions-spacing">
                                            <div id='close_button' variant="contained" color="secondary" class="item-dialogue-button idlescape-button idlescape-button-red">Close </div>
                                            <div id='sell_button' variant="contained" color="secondary" class="item-dialogue-button idlescape-button idlescape-button-green">Sell </div>
                                        </div>
                                    </div>
                                    </div>
                                    <div tabindex="0" data-test="sentinelEnd"></div>
                                </div>` ;

    let modify_auction_popup = document.createElement('div');
    modify_auction_popup.id = 'modify_auction_popup';
    modify_auction_popup.innerHTML = modify_auction_popup_html;

    // Some CSS styles are not imported. We need to reimplet it
    var style = document.createElement('style');
    style.innerHTML = `.MuiDialogActions-root {
                             flex: 0 0 auto;
                             display: flex;
                             padding: 8px;
                             align-items: center;
                             justify-content: flex-end;
                       }

                       .MuiDialogActions-spacing {
                             margin-left: 0!important;
                       }

                       .MuiDialogActions-spacing > :not(:first-child) {
                             margin-left: 8px;
                       }`;

    modify_auction_popup.appendChild(style)

    let body = document.getElementsByTagName('body')[0];
    body.appendChild(modify_auction_popup);

    let min_price = -1;

    /**
     * Retrieving the min price and setting correct fields
     */
	that.waiting_min_price(that, order_data.itemID)
        .then((min_price) => {
            if (document.getElementById('min_price' + itemID) != null) {
                document.getElementById('min_price' + itemID).innerHTML = that.int_to_commas(min_price)

                document.getElementById('min_price_button_nok').style.display = 'none';
                document.getElementById('min_price_button_ok').style.display = null;

                // Adding Adapt price function
                document.getElementById('min_price_button_ok').addEventListener('click', () => {document.getElementById('price').value = min_price - 1;});
            }
        }).catch((e) => {});

    // smoother ui, add commas to numbers
    let price_changed = false;
    let amount_changed = false;
    let update_ui = setInterval(() => {
        that.update_prices_popup(that, price_changed, amount_changed);
        price_changed = true;
		amount_changed = true;
    }, that.update_ui_rate);

    document.getElementById('sell_button').addEventListener('click', () => {

        // cancel auction
        that.sockets[0].send('42["cancel my auction",'+id+']');

        // make a new auction with the right id
        let price = that.commas_to_int(document.getElementById('price').value);
        let amount = that.commas_to_int(document.getElementById('amount').value);

        // wait to retrieve the inventory_item_id
        that.waiting_inventory_update(that, itemID)
            .then(tosell_id => {
            that.sockets[0].send('42["sell item marketplace",{"amount":'+amount+',"price":'+price+',"dbID":'+tosell_id+'}]');

        }).catch(e => that.error_handler(that, e)); // if we can't find the inventory_item_id);

        // close popup && remove ui updaters
        clearInterval(update_ui);
        that.clean_popup(that);
        price_changed = false;
        amount_changed = false;
    });

    // Waiting for boohi.... (NOK waiting message
    document.getElementById('min_price_button_nok').addEventListener('click', () => {alert("Waiting for Boohi...");});

    document.getElementById('close_button').addEventListener('click', () => {
        // close popup && remove ui updaters
        clearInterval(update_ui);
        that.clean_popup(that);
        price_changed = false;
        amount_changed = false;
    });
}

Stonehub.prototype.clean_popup = function(that) {
    document.getElementById('sell_button').removeEventListener('click');
    document.getElementById('close_button').removeEventListener('click');
    document.getElementById('min_price_button_ok').removeEventListener('click');
    document.getElementById('modify_auction_popup').outerHTML = '';

    that.sockets[0].send('42["get player auctions"]');
}

/**
 * Update price and fees in custom sell pop-up
 */
Stonehub.prototype.update_prices_popup = function(that, price_changed, amount_changed) {
	let popup_still_exists = document.getElementById('modify_auction_popup');
    if (popup_still_exists != null && popup_still_exists.length != 0) {
		let price = price_changed ? that.commas_to_int(document.getElementById('price').value) : parseInt(document.getElementById('price').value);
		let amount = amount_changed ? that.commas_to_int(document.getElementById('amount').value) : parseInt(document.getElementById('amount').value);
		let fees_percentage = 0.05;

		let to_bouilli = (price > 0 || typeof price == 'NaN') ? amount * price * fees_percentage : 1;
		let benefits = (price > 0 || typeof price == 'NaN') ? amount * price - to_bouilli : 0;

		document.getElementById('benefits').innerHTML = that.int_to_commas(Math.floor(benefits));
		document.getElementById('fees').innerHTML = that.int_to_commas(Math.floor(to_bouilli) < 1 ? 1 : Math.floor(to_bouilli));
		document.getElementById('price').value = that.int_to_commas(price);
		document.getElementById('amount').value = that.int_to_commas(amount);
	}
}

Stonehub.prototype.update_inventory_action = function(that, data) {
    /**
     * Update 2 memvars, see waiting_inventory_update for use
     */
    // /!\ there are 3 items ID
    // id, inventory_item_id and itemID

    that.itemID = data.item.itemID;
	that.inventory_item_id = data.item.id;
}

Stonehub.prototype.waiting_inventory_update = function(that, itemID) {
    /**
     * This method returns a promise when called
     * It is used in this.show_popup_sell_item() to get
     * the corresponding IDs of the item being resold.
     * It waits for the right "update inventory" event, checking
     * if the itemID of an updated item is the same as the one passed as argument
     * If so, it resolves by passing inventory_item_id
     * Repeat until you got it, or reject if the process takes too much iteration
     */
    return new Promise((resolve, reject) => {
        let c = 0;
        setTimeout(function check() {
            c = ++c;
            if(that.itemID == itemID)
                resolve(that.inventory_item_id);
            else {
                if(c >= that.NUMBER_ATTEMPT)
                    reject(new Error('timeout waiting to update inventory'));
                else
                    setTimeout(check, that.WAITING_TIMEOUT);
            }

        }, that.WAITING_TIMEOUT);
    });
}

Stonehub.prototype.waiting_min_price = function(that, raw_item_id) {
    /**
     * This method returns a promise when called
     * It is used in this.show_popup_sell_item() to get
     * the corresponding min price of the item shown in the popup
     * It will sends for "get market manifest itemID" and waits for response.
     */

    return new Promise((resolve, reject) => {

		that.sockets[0].send('42["get player marketplace items",' + raw_item_id + ']');

		let c = 0;
        setTimeout(function check() {
            c = ++c;
            if(that.raw_item_id == raw_item_id)
                resolve(that.min_price);
            else {
                if(c >= that.NUMBER_ATTEMPT)
                    reject(new Error('timeout waiting to retrieve min price'));
                else
                    setTimeout(check, that.WAITING_TIMEOUT);
            }

        }, that.WAITING_TIMEOUT);
    });
}

Stonehub.prototype.convenients_sell_item_action = function(that, data) {

    that.clean_auctions();

    /**
     * This method add some convenients and small adjustements to the sell page.
     * Current features :
     *      Button added for further features
     *      Autorefresh when someone bought the item
     */
    if(document.getElementsByClassName('crafting-table marketplace-table').length != 0) {
        // ==== AUTOREFRESH ==== //
        setTimeout(() => {
            let crafting_table_exists = document.getElementsByClassName('crafting-table marketplace-table');
            if(crafting_table_exists.length != 0)
                that.sockets[0].send('42["get player auctions",[]]');
        },  that.auto_refresh_auction_time);
    }

    // ==== STONE BUTTON ==== //
    let auction_table_tbody = document.getElementsByClassName('marketplace-my-auctions')[0].getElementsByTagName('tbody')[0].getElementsByTagName('tr');
    let auction_table_tbody_ar= Array.prototype.slice.call(auction_table_tbody);

    // for each auction in the table
    auction_table_tbody_ar.forEach((element, index) => {

        // add button
        let modify_auction_button = document.createElement('td');
        modify_auction_button.className = 'modify_auction_button';
        modify_auction_button.id = data[index].itemID;

        // add the image
        let modify_auction_img    = document.createElement('img');
        modify_auction_img.src    = 'https://idlescape.com/images/mining/bronze_pickaxe.png';
        modify_auction_img.addEventListener('mouseenter', e => e.target.src = 'https://idlescape.com/images/mining/rune_pickaxe.png');
        modify_auction_img.addEventListener('mouseleave', e => e.target.src = 'https://idlescape.com/images/mining/bronze_pickaxe.png');

        modify_auction_button.appendChild(modify_auction_img);

        // listener, popup
        modify_auction_button.addEventListener('click', () => {
            that.show_popup_sell_item(that, data[index]);
        });
        element.appendChild(modify_auction_button);
    });


}

/**
 * Delete all preexisting modifying buttons?
 * Mandatory for update
 */
Stonehub.prototype.clean_auctions = function() {

    let auction_buttons = document.getElementsByClassName('modify_auction_button');
    let auction_id = [];

    for (let i = 0; i < auction_buttons.length; i++) {
        auction_id[i] = auction_buttons[i].id;
    };

    // WARNING : It's mandatory to act in 2 times for this
    auction_id.forEach((element, index) => {
        document.getElementById(auction_id[index]).remove();

    });
}

// ==== MAIN ==== //

try {
    let sh = new Stonehub(); sh.start();
} catch(e) {new Stonehub().error_handler(that, e);}