Azke / CG Enhancer

// ==UserScript==
// @name CG Enhancer
// @namespace https://cgenhancer.azke.fr
// @version 0.4.1
// @description  Enhancer script for CodinGame platform
// @match https://www.codingame.com/*
// @copyright 2018+, Azkellas, https://github.com/Azkellas/
// @license GPL-3.0-only
// @homepage https://github.com/Azkellas/cgenhancer
// @require http://code.jquery.com/jquery-latest.js
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// ==/UserScript==


(function()
{
    'use strict';
    /* global GM_setValue, GM_getValue, GM_xmlhttpRequest, unsafeWindow */

    // options
    var useAgentModule = true;  // set to false to disable angular debug mode (and agent panel)
    var forceExternRequest = false;  // set to true to enable fighting against bots of higher leagues

    // existing notifications:
    // 'clash-invite', 'clash-over', 'invitation-accepted'
    // 'contest-scheduled', 'contest-started', 'contest-over', 'contest-soon'
    // 'new-league', 'new-league-opened', 'new-blog', 'new-comment', 'new-comment-response', 'new-puzzle', 'new-hint', 'new-level'
    // 'contribution-received', 'contribution-accepted', 'contribution-refused'
    // 'following', 'friend-registered'
    // 'achievement-unlocked'
    // 'promoted-league', 'eligible-for-next-league'
    // 'puzzle-of-the-week'
    // 'career-new-candidate', 'career-update-candidate'
    // 'feature', 'custom'
    const enableSound = false;
    const notifToRemove = ['clash-invite', 'following'];

    var GMsetValue;
    var GMgetValue;
    var GMxmlhttpRequest;

    var lastCodeBlocUpdate;

    if (typeof GM_getValue !== 'undefined')
    {
        console.log('[CG Enhancer] Tamper/Violentmoneky detected');
        GMsetValue = GM_setValue;
        GMgetValue = GM_getValue;
        GMxmlhttpRequest = GM_xmlhttpRequest;
    }
    else
    {
        console.error('[CG Enhancer] Greasemonkey is not supported');
        return;
    }

    if (!GMsetValue)
    {
        console.error('[CG Enhancer] Error: Could not detect userscript manager');
        return;
    }

    if (useAgentModule)
    {
        // required to access codingame local api
        // done first before angular has time to load
        const ngDebugStr = 'NG_ENABLE_DEBUG_INFO!';
        if (unsafeWindow.name.indexOf(ngDebugStr) === -1)
            unsafeWindow.name = ngDebugStr + unsafeWindow.name;
    }

    // jquery
    const $ = window.jQuery;
    const angular = unsafeWindow.angular;

    // agent images
    const ideImage = document.createElement('img');
    $(ideImage).attr('class', 'selectAgentImage ideImage')
        .attr('src', 'https://i.imgur.com/yNpEfYt.png')
        .attr('style', 'cursor: pointer;');  // .css doest not work

    const arenaImage = document.createElement('img');
    $(arenaImage).attr('class', 'selectAgentImage arenaImage')
        .attr('src', 'https://i.imgur.com/VkG3qnf.png')
        .attr('style', 'cursor: pointer;');  // .css doest not work

    const bossImage = document.createElement('img');
    $(bossImage).attr('class', 'selectAgentImage bossImage')
        .attr('src', 'https://i.imgur.com/bbVA7Qv.png')
        .attr('style', 'cursor: pointer;');  // .css doest not work

    const binImage = document.createElement('img');
    $(binImage).attr('class', 'binImage')
        .attr('src', 'https://i.imgur.com/HFPFSnc.png')
        .attr('style', 'cursor: pointer; right: 20px; bottom: 7px; position: absolute;');  // .css doesnt not work

    // Global variables ------------------------------------------------------------------------------------
    var pathName = '';  // url pathname
    var agentApi;  // local cg api used for global actions, like removing an agent or requesting the leaderboard
    var userPseudo;


    // last battles panel global variables -------------------------------------------------
    var blockTvViewer = false;  // boolean stating if the last battle tv is to be displayed or not (to prevent autostart when opening the tab)


    // agent managent global variables
    // stored for fast agent managing
    var bossAgent;
    var userAgent;

    // leaderboards
    var playersData = {};  // stores player agents through the leaderboard. keys: lowercase pseudos, values: agents
    var lastLeaderboardUpdate; // timer used to avoid spamming leaderboards request

    // templates construction
    const baseStyle = `cursor: auto;`;
    const rankEloBaseCss = baseStyle + `
        text-align: right;
        float: right;
        display: inline-block;
        margin-right: 30px;
    `;
    const rankPCss = rankEloBaseCss + 'font-size: 30px;';
    const eloPCss = rankEloBaseCss;
    const attrs = `contentEditable='true' spellcheck='false'`;
    const rankDivTemplate = `
        <div class='rank-div'>
            <p class='p-rank' `+attrs+` contentEditable='true' spellcheck='false' style=' {{defaultStyle}}`+ rankPCss + `'>{{value}}</p>
        </div>`;
    const eloDivTemplate  = `
        <div class='elo-div'>
            <p class='p-elo' `+attrs+` style='` + eloPCss + `{{defaultStyle}}` + `'>` + `{{value}}` + `</p>
        </div>`;
    const nameDivTemplate = `
        <div class='submission-name'>
            <p class='p-name' ` + attrs + `
                style='font-size: 18px; margin-top: 3px; float:left; display:inline-block;` + baseStyle + `{{defaultStyle}}` + `'>
                    {{value}}
            </p>
        </div>`;


    // rank over avatar template
    const rankAvatarCss = `
        text-font: bold;
        text-shadow: #000 1px 1px, #000 -1px 1px, #000 -1px -1px, #000 1px -1px;
        margin-left: 5px;
        font-weight: 600;
        color: rgb(255, 255, 255);
        position: absolute;
        bottom: 2px;
        right: 5px;
    `;


    // main function: observing all mutations
    var observer = new MutationObserver(function(mutations)
    {
        // check page name
        if ($(location).attr('pathname') !== pathName)
        {
            pathName = $(location).attr('pathname');
            console.log('[CG Enhancer] New page detected: ' + pathName);

            // reset agentApi since it's related to the current ide
            agentApi = null;
            // reset leaderboard
            lastLeaderboardUpdate = null;
        }

        // check user pseudonym
        const pseudoDiv = $('.navigation-profile_nav-profile-nickname').first();
        if (!userPseudo && pseudoDiv)
        {
            userPseudo = pseudoDiv.attr('title');
            if (userPseudo)
                console.log('[CG Enhancer] User pseudonym: ' + userPseudo);
        }

        // if not in IDE
        if ($(location).attr('pathname').indexOf('ide/') === -1)
        {
            // remove community notifications
            const contributionNav = $('#navigation-contribute');
            if (contributionNav)
            {
                const bubbleNotif = contributionNav.find('.cg-notification-bubble').first();
                if (bubbleNotif)
                    bubbleNotif.remove();
            }
        }

        // we are in the IDE and main is loaded
        if ($(location).attr('pathname').indexOf('ide/') !== -1 && $('.main').length)
        {
            if (useAgentModule)
            {
                // get agentApi if needed or disable agentModule
                if (!agentApi)
                    getAgentApi();

                handleBlocs();

                // add agent buttons
                manageAgentPanel();
            }

            // check if we opened last battles without looking at all mutations
            const firstMutation = $(mutations[0].target);
            if (firstMutation.attr('class') && firstMutation.attr('class').indexOf('cg-ide-last-battles') !== -1)
            {
                console.log('[CG Enhancer] Opened last battles');
                blockTvViewer = true;
                updatePlayersData();
            }

            // block tv viewer if opened
            if (blockTvViewer)
            {
                // hide battle tv on last battles tab opening
                const battleTv = $('.battle-tv').first();
                if (battleTv)
                {
                    // hide battleTv
                    battleTv.attr('class', 'battle-tv-hidden');

                    // reveal if clicked
                    battleTv.click(function() {
                        blockTvViewer = false;
                        $(this).attr('class', 'battle-tv');
                    });
                }
            }

            // trigger tv-battle close button
            const showButton = $('.battle-tv-hidden .battle-button-label').first();
            if (showButton && showButton.text() === 'Close')
                showButton.trigger('click');


            // add ranks on last battle tab, if open
            // this part is not updated with new leaderboard query
            if ($('.cg-ide-last-battles').length)
                manageLastBattlesTab();

            // if the history tab is open
            if ($('.cg-ide-results').length)
                manageHistoryTab();
        }
    });


    var waitingForDocument = setInterval(function()
    {
        // configuration of the observer:
        var config = { attributes: true, childList: true, characterData: true, subtree: true};

        // disallow sound for notifications
        if (unsafeWindow.session.notificationConfig.soundEnabled !== enableSound)
            unsafeWindow.session.notificationConfig.soundEnabled = enableSound;

        // remove notifications
        for (const notif of notifToRemove)
        {
            const idx = unsafeWindow.session.enabledNotifications.indexOf(notif);
            if (idx !== -1)
                unsafeWindow.session.enabledNotifications.splice(idx, 1);
        }

        console.log('[CG Enhancer] CG Enhancer is now working.');
        observer.observe(document, config);
        clearInterval(waitingForDocument);
    }, 1000);


    // helpers

    /** create swap button if no cgspunk is detected */
    function handleSwapButton()
    {
        // add swap button if not here (by cgspunk and cgenhancer)
        if ($('#cgspkSwapButton').length === 0 && $('#cgeSwapButton').length === 0)
        {
            console.log('[CG Enhancer] Add swap button');
            // code courtesy to cgspunk ( https://github.com/danBhentschel/CGSpunk/ )
            const swapButton = document.createElement('BUTTON');
            swapButton.setAttribute('id', 'cgeSwapButton');
            swapButton.innerHTML = 'SWAP';

            swapButton.style.padding = '5px 5px 5px 5px';
            const panel = $('.scroll-panel').first();
            if (panel)
                panel.append(swapButton);
            $('#cgeSwapButton').click(rotateAgents);
        }

        // remove swap button if cgspunk swap button here
        if ($('#cgspkSwapButton').length !== 0 && $('#cgeSwapButton').length !== 0)
        {
            console.log('[CG Enhancer] Remove swap button');
            const swapButton = $('#cgeSwapButton');
            swapButton.remove();
        }
    }

    /** handle blocs layout */
    function handleBlocs()
    {
        if (lastCodeBlocUpdate && (new Date() - lastCodeBlocUpdate < 100))  // max one each 0.1ms
            return;

        lastCodeBlocUpdate = new Date();


        const bloc_container = $('.blocs-container').first();
        const height = bloc_container.height();

        const top295 = (height - 295) + 'px';
        const top52  = (height -  52) + 'px';

        const agentSubmitBloc = $('.testcases-actions-container').first();
        const codeBloc = $('.code-bloc').first();
        const consoleBloc = $('.console-bloc').first();
        const statementBloc = $('.statement-bloc').first();

        const cgSyncActive = $('.code-editor-readonly').length;

        consoleBloc.find('.frame-number-bloc').each(function(idx, element) {
            if ($(element).css('right') !== '0px')
                $(element).css('right',     '0px');
        });

        // console on the right side, pair statement-agent, ide-console
        if (cgSyncActive)
        {
            // optimize console layout
            const miniLeaderboardBloc = $('.mini-leaderboard').first();
            if (miniLeaderboardBloc.css('height') !== '90px')
                miniLeaderboardBloc.css('height',     '90px');
            const maxWidth = consoleBloc.width();
            if (miniLeaderboardBloc.css('width') !== (maxWidth-2) + 'px')
                miniLeaderboardBloc.css('width',     (maxWidth-2) + 'px');
    
            const miniLeaderboard = $('.cg-ide-mini-leaderboard').first();
            if (miniLeaderboard.css('display') !== 'inline-flex')
                miniLeaderboard.css('display',     'inline-flex');
    
            const consoleContent = $('.console-content').first();
            if (consoleContent.css('left') !== '0px')
                consoleContent.css('left',     '0px');
            if (consoleContent.css('top') !== '60px')
                consoleContent.css('top',     '60px');
            
    
            miniLeaderboardBloc.find('.leaderboard-item').each(function(idx, value) {
                if ($(value).css('width') !== ( (maxWidth-25) / 4) + 'px')
                    $(value).css('width', ( (maxWidth-25) / 4) + 'px');
                if ($(value).css('margin') !== '5px')
                    $(value).css('margin',     '5px');
            });

            // swap console / agent blocs if cgsync is active
            const statementRight = statementBloc.css('right');
            const ideLeft = codeBloc.css('left');
            if (consoleBloc.css('left') !== ideLeft || agentSubmitBloc.css('right') !== statementRight)
            {
                // swap console / agent blocs
                consoleBloc.css('left', ideLeft);
                agentSubmitBloc.css('right', statementRight);

                if (consoleBloc.css('right') !== '0px')
                    consoleBloc.css('right',     '0px');
                if (consoleBloc.css('margin-top') !== '0px')
                    consoleBloc.css('margin-top',     '0px');

                if (agentSubmitBloc.css('left') !== '0px')
                    agentSubmitBloc.css('left',     '0px');

                // open console if closed
                $('.unminimize-button').click();

                // disable expand / minimize
                $('.minimize-button').attr('disabled', '');
                $('.expand-button').attr('disabled', '');
            }

            // handle layout
            // left side
            if (agentSubmitBloc.css('top') !== top295)
                agentSubmitBloc.css('top',     top295);
            if (statementBloc.css('bottom') !== '295px')
                statementBloc.css('bottom',     '295px');

            // right side
            if (codeBloc.css('display') !== 'none')
                codeBloc.css('display',     'none');
            if (consoleBloc.css('top') !== '0px')
                consoleBloc.css('top',     '0px');
        }
        else
        {
            // optimize console layout
            const miniLeaderboard = $('.mini-leaderboard').first();
            if (miniLeaderboard.css('width') !== '130px')
                miniLeaderboard.css('width',     '130px');
            const consoleContent = $('.console-content').first();
            if (consoleContent.css('left') !== '120px')
                consoleContent.css('left',     '120px');

            // swap console / agent blocs if cgsync just got inactive
            const statementRight = statementBloc.css('right');
            const ideLeft = codeBloc.css('left');
            if (consoleBloc.css('right') !== statementRight || agentSubmitBloc.css('left') !== ideLeft)
            {
                // swap console / agent blocs
                consoleBloc.css('right', statementRight);
                agentSubmitBloc.css('left', ideLeft);

                if (consoleBloc.css('margin-top') === '0px')
                    consoleBloc.css('margin-top', '');

                codeBloc.css('display', 'block');

                if (consoleBloc.css('left') !== '0px')
                    consoleBloc.css('left',     '0px');

                if (agentSubmitBloc.css('right') !== '0px')
                    agentSubmitBloc.css('right',     '0px');

                // disable expand / minimize
                $('.minimize-button').removeAttr('disabled');
                $('.expand-button').removeAttr('disabled');

                // optimize console layout
                const miniLeaderboardBloc = $('.mini-leaderboard').first();
                const miniLeaderboard = $('.cg-ide-mini-leaderboard').first();
                if (miniLeaderboardBloc.css('height') === '90px')
                    miniLeaderboardBloc.css('height',     '');
                if (miniLeaderboardBloc.css('width') === miniLeaderboard.css('width'))
                    miniLeaderboardBloc.css('width',     '');
        
                if (miniLeaderboard.css('display') === 'inline-flex')
                    miniLeaderboard.css('display',     '');
        
                const consoleContent = $('.console-content').first();
                if (consoleContent.css('left') === '0px')
                    consoleContent.css('left',     '');
                if (consoleContent.css('top') === '60px')
                    consoleContent.css('top',     '');
                
        
                miniLeaderboardBloc.find('.leaderboard-item').each(function(idx, value) {
                    if ($(value).width() > miniLeaderboard.width())
                        $(value).css('width',     '');
                    if ($(value).css('margin') === '5px')
                        $(value).css('margin',     '');
                });

            }

            // left side
            if (codeBloc.css('bottom') !== '295px')
                codeBloc.css('bottom',     '295px');
            if (agentSubmitBloc.css('top') !== top295)
                agentSubmitBloc.css('top',     top295);

            // right side
            if (!consoleBloc.find('.header-button.unminimize-button').length)
            {
                // the console is open
                if (consoleBloc.css('top') !== top295)
                    consoleBloc.css('top',     top295);
                if (statementBloc.css('bottom') !== '295px')
                    statementBloc.css('bottom',     '295px');
            }
            else
            {
                // the console is minimized
                if (consoleBloc.css('top') !== top52)
                    consoleBloc.css('top',     top52);

                if (statementBloc.css('bottom') !== '52px')
                    statementBloc.css('bottom',     '52px');
            }
        }
    }


    /** create agent fast selection tools */
    function manageAgentPanel()
    {
        // make sure agentApi is operational
        if (!agentApi)
            return;

        // create swap button if no cgspunk is detected
        handleSwapButton();

        // add buttons for each agent
        $('.agent').each(function(agentIdx, agent) {
            if ($(agent).find('.fastSelectButtons').length)
                return;

            $(agent).append(`<div class='fastSelectButtons'></div>`);
            const fastDiv = $(agent).find('.fastSelectButtons').first();
            $(ideImage).clone().appendTo(fastDiv);
            fastDiv.find('.ideImage').first().click(function() {
                addAgent(agentIdx, 'ide');
            });
            $(arenaImage).clone().appendTo(fastDiv);
            fastDiv.find('.arenaImage').first().click(function() {
                addAgent(agentIdx, 'arena');
            });
            $(bossImage).clone().appendTo(fastDiv);
            fastDiv.find('.bossImage').first().click(function() {
                addAgent(agentIdx, 'boss');
            });

            // add input
            $(agent).append(`<div class='fastInput'></div>`);
            const inputDiv = $(agent).find('.fastInput').first();
            inputDiv.append(`<input class='fastAgentInput' type='text' />`);
            const inputBox = inputDiv.find('.fastAgentInput').first();
            inputBox.keyup({'index': agentIdx}, addFastPlayer);

            inputBox
                .css('width', '80px')
                .css('height', '20px')
                .css('padding-left', '5px')
                .css('background-color', 'rgb(112, 112, 112)')
                .css('color', 'rgb(255, 255, 255)')
                .css('margin-bottom', '0px');

            updatePlayersData();
        });
    }

    /** the coloration/rank is only computed once at the opening of the tab / the end of the game */
    function manageLastBattlesTab()
    {
        $('.battle-done').not(':has(.cge-player-rank)').each(function(index, battleDiv) {
            // TODO
            // might crash for some browsers because of color conversion
            // check https://stackoverflow.com/a/11943970 for a safe way to code it

            const color = getColor(battleDiv);
            $(battleDiv).css('background-color', color);

            if (agentApi)
            {
                const battleAngular = angular.element(battleDiv);
                const battleData = battleAngular.scope().battle;

                let draw = true;
                for (const player of battleData.players)
                {
                    if (player.position)
                        draw = false;
                }

                if (draw)
                {
                    $(battleDiv).find('.player-agent')
                        .first().append('<div class="egal">=</div>');
                    const egal = $(battleDiv).find('.egal').first();
                    egal
                        .css('color', '#c90')
                        .css('font-weight', '900')
                        .css('position', 'absolute')
                        .css('top', '-17px')
                        .css('right', '-12px')
                        .css('font-size', '40px')
                        .css('-webkit-text-stroke', '1px black')
                        .css('z-index', '200');
                }
            }

            $(battleDiv).find('.player-agent').each(function(avatarIndex, playerAvatar) {
                const player = $(playerAvatar).attr('title');
                if (player && player !== userPseudo)
                {
                    const playerAgent = playersData[player.toLowerCase()];
                    const rank = playerAgent ? playerAgent.localRank : '';
                    const rankDiv =
                        `<div class='cge-player-rank' style='` + rankAvatarCss + `'>` +
                            rank +
                        `</div>`;
                    $(playerAvatar).append(rankDiv);
                }
            });
        });
    }

    /**
     * add name/rank/elo divs to submit div
     * @param {Object} options
     */
    function addSubmitDiv(options)
    {
        const storageHash = options.storageHash + options.type;
        const divOptions = {
            'storageHash': storageHash,
            'default': options.type,
            'defaultStyle': options.defaultStyle
        };
        const newDiv = getDiv(divOptions, options.template);
        options.root.append(newDiv)
            .find('.p-' + options.type).first()
            .click(clickEvent)
            .keypress({'type': options.type, 'default': options.type, 'storageHash': storageHash}, keyPressEvent);
    }

    /** handles submits naming, ranking/elo storage */
    function manageHistoryTab()
    {
        $('.submission-card').not(':has(.date-name-div)').each(function(index, submission) {
            // create left side div (date + name)
            $(submission)
                .children().not('.ide-icon_arrow_black')
                .wrapAll( '<div class="date-name-div" />');

            // date is required for storageHash
            const date = $(submission).find('.date').first();

            const storageHash = pathName + date.attr('title');

            // add flex style
            $(submission).css('display',     'flex');
            $(submission).css('flex-direction',     'column');
            $(submission).css('flex-wrap',     'wrap');


            // modify data display for an exact date
            date.text(date.attr('title'));
            date.css('font-size', '12px');


            // create icon side div (arrow + bin)
            $(submission)
                .find('.ide-icon_arrow_black')
                .wrapAll( '<div class="icons-div" />');

            const dateNameDiv = $(submission).find('.date-name-div').first();
            dateNameDiv
                .css('float', 'left')
                .css('width', '150px')
                .css('display', 'flex')
                .css('flex-direction', 'inherit')
                .css('margin-top', '-12px');

            // create right side div (rank + elo)
            $(submission).append(`<div class='rank-elo-div'></div>`);
            const rankEloDiv = $(submission).find('.rank-elo-div').first();
            rankEloDiv
                .css('width', '100px')
                .css('align-self', 'flex-end')
                .css('display', 'inline-grid');

            const iconsDiv = $(submission).find('.icons-div');
            $(binImage).clone().appendTo(iconsDiv);
            iconsDiv
                .find('.binImage')
                .click(function(event) {
                    $(submission).css('display', 'none');
                    GMsetValue(storageHash + 'display', 'none'); /* jshint ignore:line */
                    event.stopPropagation();
                });

            // add name storage
            const options = {};
            // commun options
            options.storageHash = storageHash;
            options.defaultStyle = 'color: rgb(224, 224, 224);';

            // add name storage
            options.type = 'name';
            options.template = nameDivTemplate;
            options.root = dateNameDiv;
            addSubmitDiv(options);

            // add rank storage
            options.type = 'rank';
            options.template = rankDivTemplate;
            options.root = rankEloDiv;
            addSubmitDiv(options);

            // add elo storage
            options.type = 'elo';
            options.template = eloDivTemplate;
            options.root = rankEloDiv;
            addSubmitDiv(options);


            const display = GMgetValue(storageHash + 'display'); /* jshint ignore:line */
            console.log(storageHash + 'display' + ': ' + display);
            if (display === 'none')
            {
                $(submission).css('display', 'none');
                return;
            }
        });

        if ($('.cg-ide-submissions').length && $('.restoreDiv').length === 0)
        {
            $('.cg-ide-submissions').append('<div class="restoreDiv">restore all</div>');
            $('.restoreDiv').first()
                .css('position', 'absolute')
                .css('bottom', '30px')
                .css('right', '40px')
                .css('color', '#aaaaaa')
                .css('cursor', 'pointer')
                .click(function(event) {
                    $('.submission-card').each(function(index, submission) {
                        // date is required for storageHash
                        const date = $(submission).find('.date').first();

                        const storageHash = pathName + date.attr('title');
                        if ($(submission).css('display') === 'none')
                        {
                            $(submission).css('display', 'flex');
                            GMsetValue(storageHash + 'display', 'flex'); /* jshint ignore:line */
                            return;
                        }
                    });
                    manageHistoryTab();
                    event.stopPropagation();
                });
        }
    }


    /** try to get angular api or disable agent panel */
    function getAgentApi()
    {
        const agentForApi = $('.agent').filter(':first');
        if (useAgentModule && agentForApi)
        {
            // if angular is indeed in debug mode
            if (angular.element(agentForApi).scope())
                agentApi = angular.element(agentForApi).scope().api;
            else
            {
                console.error('[CG Enhancer] Please refresh the tab to use the agent module. ' +
                              'If it doesn\'t work, ask Azkellas or post on the forum/github');
                useAgentModule = false;
            }
        }
        else
        {
            console.warn('[CG Enhancer] Agent panel is not fully loaded yet');
        }
    }

    /**
     * @param {int} index - index of agent scope to apply
     */
    function applyAgent(index)
    {
        angular.element('.agent').eq(index)
            .scope().$apply();
    }

    /**
     * @param {int} index - index of agent to remove
     */
    function removeAgent(index)
    {
        agentApi.removeAgent(index);
        applyAgent(index);
    }

    /**
     * @param {int} index - index of agent to add
     * @param {string} type - type or pseudo of agent to add
     */
    function addAgent(index, type)
    {
        removeAgent(index);
        const agent = angular.element('.agent').eq(index);

        if (type === 'ide')
            agent.scope().api.addAgent({'agentId': -1});
        if (type === 'arena')
        {
            if (userAgent)
                agent.scope().api.addAgent(userAgent);
            else
                type = 'boss'; // the player did not submit any AI yet
        }
        if (type === 'boss')
        {
            if (bossAgent)
                agent.scope().api.addAgent(bossAgent);
            else  // could not find the boss (the player is in legned league)
                agent.scope().api.addAgent({'agentId': -2});  // -2 is the defaultAI agentId
        }
        if (type !== 'ide' && type !== 'arena' && type !== 'boss')  // type is a real player, not the best way to code it
        {
            agent.scope().api.addAgent(playersData[type]);
        }

        applyAgent(index);
    }

    /**
     * rotate agents
     */
    function rotateAgents()
    {
        // code partly courtesy to cgspunk ( https://github.com/danBhentschel/CGSpunk/ )
        console.log('[CG Enhancer] Rotating agents');
        const agents = [];
        // get agents

        $('.agent').each(function(index, agent) {
            agent = angular.element(agent);
            if (agent.scope().$parent.agent !== null) // check if there is indeed an agent or if the agent is empty
                agents.push(agent.scope().$parent.agent);
        });

        // shift agents
        agents.push(agents.shift());

        // add agents
        for (let index = 0; index < agents.length; index++) {
            removeAgent(index);
            const agent = angular.element('.agent').eq(index);
            agent.scope().api.addAgent(agents[index]);
            applyAgent(index);
        }
    }

    /**
     * use templates to create the div required
     * @param {object} data - contains all options required to generate the div
     * @param {string} template - html template to use
     */
    function getDiv(data, template)
    {
        // data: {storageHash, default, defaultStyle}
        const name = GMgetValue(data.storageHash, data.default); /* jshint ignore:line */
        console.log(data.storageHash + ': ' + name);
        let style = '';
        if (name === data.default)
            style = data.defaultStyle;
        template = template.replace('{{value}}', name);
        template = template.replace('{{defaultStyle}}', style);
        return template;
    }

    /**
     * return the color highlight of the battle in the last battle tabs
     * difference to determine if the result is unexpected is
     * enemyRank > 1.2*userRank + 10  (randomly chosen)
     * note: this function does not check for draws, but check wins by looking at which player is displayed first
     * note: giving rgb values is mandatory for firefox
     * @param {jquery object} battleDiv
     */
    function getColor(battleDiv)
    {
        // if more than 2 players, not coloration
        if ($(battleDiv).find('.player-agent').length > 2)
            return 'rgb(255, 255, 255)';

        // userAgent undefined
        if (!userAgent)
            return 'rgb(255, 255, 255)';

        let userRank;
        let enemyRank;
        let userWon;

        $(battleDiv).find('.player-agent').each(function(playerIdx, playerAvatar) {
            const player = $(playerAvatar).attr('title');
            if (player && playersData[player.toLowerCase()] && player !== userPseudo)
                enemyRank = playersData[player.toLowerCase()].localRank;
            if (player && player === userPseudo)
            {
                userRank = userAgent.localRank;
                userWon = (playerIdx === 0);
            }
        });

        // at least one undefined rank
        if (!userRank || !enemyRank)
            return 'rgb(240, 240, 240)';

        // unexpected loss
        if (enemyRank > 1.2*userRank + 10 && !userWon)
            return 'rgb(255, 240, 240)';

        // unexepcted win
        if (userRank > 1.2*enemyRank + 10 && userWon)
            return 'rgb(240, 255, 240)';

        // expected result
        return 'rgb(255, 255, 255)';
    }

    /**
     * stop propagation
     * @param {DOM event} event
     */
    function clickEvent(event)
    {
        /* jshint validthis: true */
        if (!this)
        {
            console.error('[CG Enhancer] Error: clickEvent must be called inside a click method.');
            return;
        }
        // prevent codingame action
        event.stopPropagation();
    }

    /**
     * poorly named function
     * it is called when the user tries to select an agent by its pseudo
     * @param {DOM event} event
     */
    function addFastPlayer(event)
    {
        /* jshint validthis: true */
        if (!this)
        {
            console.error('[CG Enhancer] Error: addFastPlayer must be called inside a keypress method.');
            return;
        }

        const key = event.which;
        if (key === 13)  // enter key
        {
            const pseudo = $(this).val();

            // add existing player
            if (pseudo && playersData[pseudo.toLowerCase()])
            {
                console.log('[CG Enhancer] Player ' + pseudo + ' found');
                addAgent(event.data.index, pseudo.toLowerCase());
                $(this).text('');  // reset pseudo
                $(this).css('color', 'rgb(255, 255, 255');  // reset color
                $(this).blur();  // focus out
            }
            // player not found
            else
            {
                console.log('[CG Enhancer] Player ' + pseudo + ' could not be found');
                $(this).css('color', 'rgb(255, 160, 160)');  // red coloration if player not found
            }

            // prevent codingame action
            event.stopPropagation();
        }
        else
        {
            // reset color to white
            $(this).css('color', 'rgb(255, 255, 255)');
        }
    }

    /**
     * called in the history tab
     * @param {DOM event} event - contains data
     */
    function keyPressEvent(event)
    {
        /* jshint validthis: true */
        // event.data :
        //    {
        //      type
        //      defaultValue
        //      storageHash
        //    }

        if (!this)
        {
            console.error('[CG Enhancer] Error: keyPressEvent must be called inside a keyUp method.');
            return;
        }

        const key = event.which;
        if (key === 13)  // enter key
        {
            // lose focus
            $(this).blur();  // lose focus


            // make sure no empty value is stored
            if ($(this).text() === '')
                $(this).text(event.data.default);

            // save value (even if default to erase previous value)
            GMsetValue(event.data.storageHash, $(this).text()); /* jshint ignore:line */

            // apply coloration
            if ($(this).text() !== event.data.default)
                $(this).css('color', '');
            else
                $(this).css('color', 'rgb(224, 224, 224)');

            // prevent codingame action
            event.stopPropagation();
        }
    }

    /**
     * update playersData to store agentId and ranks
     */
    function updatePlayersData()
    {
        // angular is not activated
        if (useAgentModule === false)
            forceExternRequest = true;
        // make sure we do not update every 5 sec
        // at most once every minute
        if (lastLeaderboardUpdate && (new Date() - lastLeaderboardUpdate < 60*1000))
            return;

        // reset stored leaderboard and user/boss agents
        lastLeaderboardUpdate = new Date();
        const newPlayersData = {};
        userAgent = null;
        bossAgent = null;

        // we get the leaderboard through the API
        if (!forceExternRequest && agentApi)
        {
            console.log('[CG Enhancer] Requesting the leaderboard through agentApi');

            agentApi.getLeaderboard()
                .then(function(result) {
                    // direct access to user agent
                    userAgent = result.codingamerUserRank;

                    for (const user of result.users)
                    {
                        if (user.pseudo)
                        {
                            newPlayersData[user.pseudo.toLowerCase()] = user;
                            if (user.arenaboss && (!userAgent || userAgent.league.divisionIndex === user.league.divisionIndex))
                                bossAgent = user;
                        }
                    }
                    // only update playersData at the end to prevent issue #1
                    playersData = newPlayersData;
                })
                .catch(function(error) {
                    console.error('[CG Enhancer] api request failed with error: ' + error);
                });
        }
        // we make an extern api request since we don't have the agentAPI
        else
        {
            console.log('[CG Enhancer] Requesting the leaderboard through an extern request');
            const gameSplit = pathName.split('/');
            const multi = gameSplit.slice(-1)[0];
            let api = '';
            if (gameSplit.slice(-2)[0] === 'puzzle')
                api = 'getFilteredPuzzleLeaderboard';
            else
                api = 'getFilteredChallengeLeaderboard';
            GMxmlhttpRequest({ /* jshint ignore:line */
                url: 'https://www.codingame.com/services/LeaderboardsRemoteService/' + api,
                method: 'POST',
                responseType: 'json',
                data: '[' + multi + ', undefined, global, { active: false, column: undefined, filter: undefined}]',
                onload: function(response) {
                    const rawLeaderboard = response.response.success;
                    const users = rawLeaderboard.users;
                    for (const user of users)
                    {
                        if (user.pseudo)
                        {
                            newPlayersData[user.pseudo.toLowerCase()] = user;
                            newPlayersData[user.pseudo.toLowerCase()].rank = user.localRank;  // to avoid wrong rank in agent panel when selected
                            if (user.pseudo === userPseudo)
                                userAgent = user;
                        }
                    }
                    // only update playersData at the end to prevent issue #1
                    playersData = newPlayersData;
                }
            });
        }
    }
})();