Hentai Heroes+++

Few QoL improvement and additions for competitive PvP players.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name            Hentai Heroes+++
// @description     Few QoL improvement and additions for competitive PvP players.
// @version         0.20.1
// @match           https://*.hentaiheroes.com/*
// @match           https://nutaku.haremheroes.com/*
// @match           https://*.gayharem.com/*
// @match           https://*.comixharem.com/*
// @match           https://*.hornyheroes.com/*
// @match           https://*.pornstarharem.com/*
// @match           https://*.transpornstarharem.com/*
// @match           https://*.gaypornstarharem.com/*
// @run-at          document-body
// @namespace       https://gitlab.com/hentaiheroes/hh-plus-plus-plus
// @grant           none
// @license         MIT
// @author          430i
// ==/UserScript==


const {$, location, localStorage: storage} = window;

// localStorage keys
const LS_CONFIG_NAME = 'HHPlusPlusPlus'
const LEAGUE_BASE_KEY = LS_CONFIG_NAME + ".League";
const LEAGUE_SNAPSHOT_BASE_KEY = LEAGUE_BASE_KEY + ".Snapshot";
const CURRENT_LEAGUE_SNAPSHOT_KEY = LEAGUE_SNAPSHOT_BASE_KEY + ".Current";
const PREVIOUS_LEAGUE_SNAPSHOT_KEY = LEAGUE_SNAPSHOT_BASE_KEY + ".Previous";
const LEAGUE_PLAYERS_KEY = LEAGUE_BASE_KEY + ".Players";
const EQUIPMENT_KEY = LS_CONFIG_NAME + ".Equipment";
const EQUIPMENT_CURRENT_KEY = EQUIPMENT_KEY + ".Current";
const EQUIPMENT_BEST_MYTHIC_KEY = EQUIPMENT_KEY + ".Mythic";

// 3rd party localStorage keys
const LS_CONFIG_HHPLUSPLUS_NAME = 'HHPlusPlus'
const HHPLUSPLUS_OPPONENT_FILTER = LS_CONFIG_HHPLUSPLUS_NAME + "OpponentFilter"

// CONFIG
const MAX_NUM_SNAPSHOTS = 230;
const BOOSTER_EXPIRATION_MULTIPLIER = 1.01;

// icon paths
const PATH_GROUPS = '<path d="M4,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2C2,12.1,2.9,13,4,13z M5.13,14.1C4.76,14.04,4.39,14,4,14 c-0.99,0-1.93,0.21-2.78,0.58C0.48,14.9,0,15.62,0,16.43V18l4.5,0v-1.61C4.5,15.56,4.73,14.78,5.13,14.1z M20,13c1.1,0,2-0.9,2-2 c0-1.1-0.9-2-2-2s-2,0.9-2,2C18,12.1,18.9,13,20,13z M24,16.43c0-0.81-0.48-1.53-1.22-1.85C21.93,14.21,20.99,14,20,14 c-0.39,0-0.76,0.04-1.13,0.1c0.4,0.68,0.63,1.46,0.63,2.29V18l4.5,0V16.43z M16.24,13.65c-1.17-0.52-2.61-0.9-4.24-0.9 c-1.63,0-3.07,0.39-4.24,0.9C6.68,14.13,6,15.21,6,16.39V18h12v-1.61C18,15.21,17.32,14.13,16.24,13.65z M8.07,16 c0.09-0.23,0.13-0.39,0.91-0.69c0.97-0.38,1.99-0.56,3.02-0.56s2.05,0.18,3.02,0.56c0.77,0.3,0.81,0.46,0.91,0.69H8.07z M12,8 c0.55,0,1,0.45,1,1s-0.45,1-1,1s-1-0.45-1-1S11.45,8,12,8 M12,6c-1.66,0-3,1.34-3,3c0,1.66,1.34,3,3,3s3-1.34,3-3 C15,7.34,13.66,6,12,6L12,6z"/>';
const PATH_GROUP = '<path d="M9 13.75c-2.34 0-7 1.17-7 3.5V19h14v-1.75c0-2.33-4.66-3.5-7-3.5zM4.34 17c.84-.58 2.87-1.25 4.66-1.25s3.82.67 4.66 1.25H4.34zM9 12c1.93 0 3.5-1.57 3.5-3.5S10.93 5 9 5 5.5 6.57 5.5 8.5 7.07 12 9 12zm0-5c.83 0 1.5.67 1.5 1.5S9.83 10 9 10s-1.5-.67-1.5-1.5S8.17 7 9 7zm7.04 6.81c1.16.84 1.96 1.96 1.96 3.44V19h4v-1.75c0-2.02-3.5-3.17-5.96-3.44zM15 12c1.93 0 3.5-1.57 3.5-3.5S16.93 5 15 5c-.54 0-1.04.13-1.5.35.63.89 1 1.98 1 3.15s-.37 2.26-1 3.15c.46.22.96.35 1.5.35z"/>';
const PATH_CLEAR = '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>';

class EquipmentCollector {
    static collect() {
        if (!HHPlusPlus.Helpers.isCurrentPage('shop')) {
            return;
        }

        HHPlusPlus.Helpers.defer(() => {
            setTimeout(() => {
                EquipmentCollector.collectEquipmentData();
            }, 250);

            HHPlusPlus.Helpers.onAjaxResponse(/action=market_equip_armor*/, EquipmentCollector.onEquipmentChange);
        });
    }

    static onEquipmentChange(data) {
        // We need to delay the execution a bit to give chance to the native callback to run.
        setTimeout(() => {
            // The is a bug in HH (quelle surprise) when swapping equipments - the native callbacks adds the
            // unequipped armor to the back of the inventory ('player_inventory'), but the equipped armor is not
            // removed, which leaves an inconsistent state, so we need to remove it ourselves.
            EquipmentCollector.removeEquippedArmorFromInventory(data.equipped_armor);
            EquipmentCollector.collectEquipmentData();
        }, 100);
    }

    static removeEquippedArmorFromInventory(equipped) {
        const idx = player_inventory.armor.findIndex(e => {
            return e.item.rarity == "mythic" &&
                   e.resonance_bonuses.class.bonus == equipped.resonance_bonuses.class.bonus &&
                   e.resonance_bonuses.class.identifier == equipped.resonance_bonuses.class.identifier &&
                   e.resonance_bonuses.class.resonance == equipped.resonance_bonuses.class.resonance &&
                   e.resonance_bonuses.theme.bonus == equipped.resonance_bonuses.theme.bonus &&
                   e.resonance_bonuses.theme.identifier == equipped.resonance_bonuses.theme.identifier &&
                   e.resonance_bonuses.theme.resonance == equipped.resonance_bonuses.theme.resonance &&
                   e.skin.id_item_skin === equipped.skin.id_item_skin &&
                   e.skin.id_skin_set === equipped.skin.id_skin_set &&
                   e.skin.identifier === equipped.skin.identifier &&
                   e.skin.name === equipped.skin.name &&
                   e.skin.subtype === equipped.skin.subtype;
        });

        // player_inventory.armor[idx] = data.unequipped_armor;
        player_inventory.armor.splice(idx, 1);
    }

    static collectEquipmentData() {
        EquipmentCollector.collectPlayerEquipment();
        EquipmentCollector.collectBestMythicEquipment();
    }

    static collectPlayerEquipment() {
        const eqElements = $("div#equiped.armor-container div.slot:not(:empty)[subtype!='0']");
        if (eqElements.length != 6) {
            console.log("Did not find 6 equipment elements.");
            return;
        }

        const equipment = eqElements.map(function() { return $(this).data("d")}).get();
        const equipmentStripped = equipment.map((e) => {
            return {
                id: e.id_member_armor_equipped || e.id_member_armor, // unique item identifier?
                rarity: e.item.rarity, // legendary, mythic
                type: e.item.type, // always "armor"
                skin_id: e.skin.identifier, // EH13, ET21 etc
                subtype: parseInt(e.skin.subtype), // 1, 2, 3, 4, 5 or 6
                carac1: parseInt(e.caracs.carac1),
                carac2: parseInt(e.caracs.carac2),
                carac3: parseInt(e.caracs.carac3),
                harmony: parseInt(e.caracs.chance),
                endurance: parseInt(e.caracs.endurance),
                bonuses: e.resonance_bonuses,
            };
        });

        window.localStorage.setItem(EQUIPMENT_CURRENT_KEY, JSON.stringify(equipmentStripped));
    }

    static collectBestMythicEquipment() {
        const hero = window.Hero ?? shared.Hero;

        const equipment = player_inventory.armor
            .filter(a => a.item.rarity == "mythic")
            .filter(a => parseInt(a.resonance_bonuses.class.identifier) == hero.infos.class)
            .filter(a => a.resonance_bonuses.class.resonance == "damage")
            .filter(a => a.resonance_bonuses.theme.resonance == "defense");

        window.localStorage.setItem(EQUIPMENT_BEST_MYTHIC_KEY, JSON.stringify(equipment));
    }

    static getCurrent() {
        return JSON.parse(window.localStorage.getItem(EQUIPMENT_CURRENT_KEY)) || [];
    }

    static getBestMythic() {
        return JSON.parse(window.localStorage.getItem(EQUIPMENT_BEST_MYTHIC_KEY)) || [];
    }
}

class LeaguePlayersCollector {
    static collect() {
        if (!HHPlusPlus.Helpers.isCurrentPage('leagues')) {
            return;
        }

        HHPlusPlus.Helpers.defer(() => {
            HHPlusPlus.Helpers.onAjaxResponse(/action=fetch_hero&id=profile/, LeaguePlayersCollector.collectPlayerPlacementsFromAjaxResponse);
            LeaguePlayersCollector.collectPlayerData();
        });
    }

    static collectPlayerPlacementsFromAjaxResponse(response, opt) {
        // If you are reading this, please look away, ugly code below
        // The mythic equipment data is actually not in the html, but in the form of a script that we have to eval
        const html = $("<div/>").html(response.html);
        $.globalEval(html.find('script').text()); // creates 'hero_items'

        const id = html.find("div.ranking_stats .id").text().match(/\d+/)[0];
        const username = html.find(".hero_info h3 .hero-name").text();
        const level = html.find('div[hero="level"]').text().trim();
        const number_mythic_equipment = Object.values(hero_items).filter(i => i.item.rarity == "mythic").length;
        const d3_placement = $("<div/>")
                            .html(html)
                            .find('div.history-independent-tier:has(img[src*="/9.png"]) span') // 9.png is D3
                            .map(function() {return parseInt($(this).text().trim().match(/\d+/));})
                            .get();

        if (!id || !username || !level) {
            window.popup_message("Error when parsing player data.");
            return;
        }

        if (!d3_placement || d3_placement.length != 2) {
            // make sure our parser is working by checking the D2 data
            const d2_placement = $("<div/>")
                                    .html(html)
                                    .find('div.history-independent-tier:has(img[src*="/8.png"]) span') // 8.png is D2
                                    .map(function() {return parseInt($(this).text().trim().match(/\d+/));})
                                    .get();

            if (d2_placement.length != 2) {
                window.popup_message("Error when parsing D2 player data.");
            }

            d3_placement.push(-1, 0);
        }

        const data = {
            id: parseInt(id),
            number_mythic_equipment,
            best_placement: d3_placement[0],
            placement_count: d3_placement[1],
        };

        LeaguePlayersCollector.storePlayerData(data);
        $(document).trigger('player:update-profile-data', {id: data.id})
    }

    static collectPlayerData() {
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const player = window.opponents_list[r];

            const girls = player.player.team.girls;
            const girl_levels = girls.map(g => g.level);
            const girl_levels_max = Math.max(...girl_levels);
            const girl_levels_total = girl_levels.reduce((a, b) => a + b, 0);
            const girl_levels_avg = Math.floor(girl_levels_total / girl_levels.length);

            const data = {
                id: parseInt(player.player.id_fighter),
                username: player.player.nickname,
                level: parseInt(player.player.level),
                damage: player.player.damage,
                defense: player.player.defense,
                harmony: player.player.chance,
                ego: player.player.remaining_ego,
                power: player.player.team.total_power,
                club_id: player.player.club?.id_club,
                club_name: `"${player.player.club?.name || ''}"`,
                girl_levels_avg,
                girl_levels_max,
            }

            LeaguePlayersCollector.storePlayerData(data);
        }
    }

    static storePlayerData(data) {
        const players = JSON.parse(storage.getItem(LEAGUE_PLAYERS_KEY)) || {};
        if (players[data.id] == undefined) {
            players[data.id] = {};
        }

        Object.assign(players[data.id], data);

        storage.setItem(LEAGUE_PLAYERS_KEY, JSON.stringify(players));
    }

    static export() {
        const columns = [
            "id",
            "username",
            "level",
            "damage",
            "defense",
            "harmony",
            "ego",
            "power",
            "club_id",
            "club_name",
            "girl_levels_max",
            "girl_levels_avg",
            "expected_points",
            "number_mythic_equipment",
            "best_placement",
            "placement_count",
        ]

        const players = JSON.parse(storage.getItem(LEAGUE_PLAYERS_KEY)) || {};
        const data = Object.values(players).map(player => columns.map(column => player[column]));

        console.log([columns].concat(data).map(t => t.join(",")).join("\n"));
    }

    static clear() {
        storage.removeItem(LEAGUE_PLAYERS_KEY);
    }
}

class MyModule {
    constructor ({name, configSchema}) {
        this.group = '430i'
        this.name = name
        this.configSchema = configSchema
        this.hasRun = false

        this.insertedRuleIndexes = []
        this.sheet = HHPlusPlus.Sheet.get()
    }

    insertRule (rule) {
        this.insertedRuleIndexes.push(this.sheet.insertRule(rule))
    }

    tearDown () {
        this.insertedRuleIndexes.sort((a, b) => b-a).forEach(index => {
            this.sheet.deleteRule(index)
        })

        this.insertedRuleIndexes = []
        this.hasRun = false
    }
}

class LeagueScoutModule extends MyModule {
    constructor () {
        const baseKey = 'leagueScout'
        const configSchema = {
            baseKey,
            default: true,
            label: `Gather information about league opponents`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('leagues')}

    run () {
        if (this.hasRun || !this.shouldRun()) {return}

        $(document).on('league:rollover', () => {
            const data = LeagueScoutModule.getCurrent();

            LeagueScoutModule.deleteCurrent();
            LeagueScoutModule.setPrevious(data);
            LeaguePlayersCollector.clear();
        })

        HHPlusPlus.Helpers.defer(() => {
            // read and store data
            this.storeSnapshot(this.readSnapshot());

            // create ui elements
            HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_buttons_block', () => {
                const parent = $('div.league_buttons');
                this.createDownloadButton(parent, PREVIOUS_LEAGUE_SNAPSHOT_KEY, PATH_GROUPS);
                this.createClearButton(parent, PREVIOUS_LEAGUE_SNAPSHOT_KEY);
                this.createDownloadButton(parent, CURRENT_LEAGUE_SNAPSHOT_KEY, PATH_GROUP);
                this.createClearButton(parent, CURRENT_LEAGUE_SNAPSHOT_KEY);
            });
        });

        this.hasRun = true;
    }

    readPlayerData() {
        const data = {};

        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const player = window.opponents_list[r];

            const id = parseInt(player.player.id_fighter);
            const name = player.player.nickname;
            const country = player.country;
            const level = parseInt(player.player.level);

            const entry = {id, name, country, level};
            if (Object.values(entry).some(x => x == undefined || (typeof x !== 'string' && !Array.isArray(x) && isNaN(x)))) {
                console.log('Some player data is missing, maybe the opponents_list data structure changed?');
                console.log(entry);
            }

            data[id] = {name, country, level};
        }

        return data;
    }

    readSnapshot() {
        const data = [];

        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const player = window.opponents_list[r];

            const id = parseInt(player.player.id_fighter);
            const rank = player.place;
            const points = parseInt(player.player_league_points);
            const elements = player.player.team.theme;

            const damage = player.player.damage;
            const defense = player.player.defense;
            const ego = player.player.remaining_ego;
            const chance = player.player.chance;
            const power = player.player.team.total_power;

            // Take only the first two chars of the booster names, as those should be unique. Assume all are legendary.
            const boosters = player.boosters.filter(b => b.expiration > 0).map(b => b.item.name.slice(0, 2))

            // Create player snapshot and validate it.
            const entry = {id, rank, elements, points, power, damage, defense, ego, chance, boosters};
            if (Object.values(entry).some(x => x == undefined || (typeof x !== 'string' && !Array.isArray(x) && isNaN(x)))) {
                console.log('Some player data is missing, maybe the opponents_list data structure changed?');
                console.log(entry);
            }

            data.push(entry);
        }

        // Sort the parsed data by rank.
        data.sort((a, b) => a.rank > b.rank);

        return data;
    }

    storeSnapshot(snapshot_data) {
        var data = LeagueScoutModule.getCurrent();

        const current_date = new Date(window.server_now_ts * 1000);
        const league_end_date = new Date(window.server_now_ts * 1000 + window.season_end_at * 1000);

        // Create the initial container data structure
        if (Object.keys(data).length == 0) {
            data = {
                league_end: league_end_date,
                num_players: data.length,
                player_data: this.readPlayerData(),
                snapshots: [],
            }
        }

        const snapshot = {
            date: current_date,
            snapshot: snapshot_data,
        }

        if (data.snapshots.length && JSON.stringify(data.snapshots[data.snapshots.length - 1].snapshot) === JSON.stringify(snapshot.snapshot)) {
            return;
        }

        if (data.snapshots.length >= MAX_NUM_SNAPSHOTS) {
            var previous = LeagueScoutModule.getPrevious();

            // delete an entry either from the previous league snapshots or from the current
            if (previous && previous.snapshots && previous.snapshots.length > 0) {
                previous.snapshots.shift()
                LeagueScoutModule.setPrevious(previous);
            } else {
                data.snapshots.shift();
            }
        }

        data.snapshots.push(snapshot);
        LeagueScoutModule.setCurrent(data);
    }

    createButton(id, path) {
        return `<svg id="${id}" class="blue_button_L" width="32" height="32" style="padding: 5px" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="#FFFFFF"><g><rect fill="none" height="24" width="24"/></g><g>${path}</g></svg>`
    }

    createDownloadButton(parent, what, icon) {
        if (!storage.getItem(what)) {
            return;
        }

        const friendlyId = what.toLowerCase().replaceAll(".", "-");
        const buttonId = `download-${friendlyId}`;

        const downloadButton = this.createButton(buttonId, icon);
        parent.append(downloadButton);

        $(document.body).on('click', `#${buttonId}`, () => {
            const data = LeagueScoutModule.get(what);

            const separator = ","
            const columns = ["date", "player_id", "player_name", "player_rank", "player_points", "player_power", "player_damage", "player_defense", "player_ego", "player_chance", "player_boosters"];
            const values = data.snapshots.flatMap((e) => e.snapshot.map((p) => [e.date, p.id, data.player_data[p.id].name, p.rank, p.points, p.power, p.damage, p.defense, p.ego, p.chance, `"${p.boosters.join(",")}"`].join(separator)));

            let csvContent = `sep=${separator}\n` + columns.join(separator) + "\n" + values.join("\n");

            var element = document.createElement('a');
            element.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(csvContent));
            element.setAttribute('download', `${friendlyId}.csv`);

            element.style.display = 'none';
            document.body.appendChild(element);

            element.click();

            document.body.removeChild(element);
        });
    }

    createClearButton(parent, what) {
        if (!storage.getItem(what)) {
            return;
        }

        const friendlyId = what.toLowerCase().replaceAll(".", "-");
        const buttonId = `clear-${friendlyId}`;

        const clearButton = this.createButton(buttonId, PATH_CLEAR);
        parent.append(clearButton);

        $(document.body).on('click', `#${buttonId}`, () => {
            storage.removeItem(what);
        });
    }

    static getCurrent() {
        return LeagueScoutModule.get(CURRENT_LEAGUE_SNAPSHOT_KEY);
    }

    static getPrevious() {
        return LeagueScoutModule.get(PREVIOUS_LEAGUE_SNAPSHOT_KEY);
    }

    static get(key) {
        var data = JSON.parse(storage.getItem(key)) || {};

        // Migrate from the old data structure
        if (Array.isArray(data) && data.length > 0) {
            const last_snapshot = data[data.length - 1];

            const reduceP = ({id, name, country, level}) => ({id, name, country, level});
            const reduceS = ({id, rank, elements, points, damage, defense, ego, chance, boosters}) => ({id, rank, elements, points, damage, defense, ego, chance, boosters});

            const player_data = last_snapshot.player_data.map(reduceP).reduce((map, obj) => {
                map[obj.id] = obj;
                return map;
            }, {});

            const snapshots = data.map(d => ({date: d.date, snapshot: d.player_data.map(reduceS)}));

            data = {
                league_end: last_snapshot.league_end,
                num_players: last_snapshot.num_players,
                player_data,
                snapshots,
            }
        }

        return data;
    }

    static deleteCurrent() {
        storage.removeItem(CURRENT_LEAGUE_SNAPSHOT_KEY);
    }

    static setCurrent(data) {
        this.set(CURRENT_LEAGUE_SNAPSHOT_KEY, data);
    }

    static setPrevious(data) {
        this.set(PREVIOUS_LEAGUE_SNAPSHOT_KEY, data);
    }

    static set(key, data) {
        if (data.snapshots.length > 0) {
            storage.setItem(key, JSON.stringify(data));
        } else {
            storage.removeItem(key);
        }
    }
}

class LeagueTableModule extends MyModule {
    constructor () {
        const baseKey = 'leagueTable'
        const configSchema = {
            baseKey,
            default: true,
            label: `Extend league table with additional opponents' information`,
            subSettings: [
                {
                    key: 'girl_power',
                    label: 'Show girl power in the league table',
                    default: false
                },
                {
                    key: 'kinkoid_power',
                    label: 'Show the new power stat in the league table',
                    default: false
                },
                {
                    key: 'number_of_bulbs',
                    label: 'Show the number of invested bulbs in the league table',
                    default: true
                },
                {
                    key: 'load_player_data',
                    label: 'Load player data on league table row click',
                    default: true
                },
            ],
        }
        super({name: baseKey, configSchema})

        this.all_new_columns = ['kinkoid_power', 'girl_power', 'number_of_bulbs'];
        this.anchor_column = 'power';
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('leagues')}

    run(config) {
        if (this.hasRun || !this.shouldRun()) {return}

        HHPlusPlus.Helpers.defer(() => {
            HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_table', () => {
                this.extendLeagueDataModel();
                this.addPlayerSelectHandler(config);
                this.showPlayersPlacementBadge(config);
                this.showAdditionalTableHeaders(config);
                this.showAdditionalTableColumns(config);
                this.showMaxPointsTooltip();
                this.detectReallyExpiredBoosers();
                this.detectInflatedPower();
                this.makeCompatibleWithLeaguePlusPlus();
            });

            $(document).on('player:update-profile-data', (event, data) => {
                this.extendLeagueDataModel();
                this.showPlayersPlacementBadge(config);
            });

            $(document).on('league:table-sorted', () => {
                this.showPlayersPlacementBadge(config);
                this.showAdditionalTableColumns(config);
                this.showMaxPointsTooltip(config);
                this.detectReallyExpiredBoosers();
                this.detectInflatedPower();
                this.makeCompatibleWithLeaguePlusPlus();
            });
        });

        this.hasRun = true;
    }

    extendLeagueDataModel() {
        const players_data = JSON.parse(storage.getItem(LEAGUE_PLAYERS_KEY)) || {};

        // add power to the existing `opponents_list` data model
        for (var r = 0, n = opponents_list.length; r < n; r++) {
            const player = opponents_list[r];
            const id = parseInt(player.player.id_fighter);

            const player_data = players_data[id];
            const best_placement = player_data != undefined ? player_data.best_placement : -1;
            const placement_count = player_data != undefined ? player_data.placement_count : -1;

            player.best_placement = best_placement;
            player.placement_count = placement_count;

            player.kinkoid_power = number_reduce(player.player.team.power_display);
            player.girl_power = player.player.team.total_power.toFixed();
            player.number_of_bulbs = player.player.team.girls.flatMap(g => Object.values(g.skill_tiers_info)).reduce((a,g)=>a+g.skill_points_used, 0)
        }
    }

    showAdditionalTableHeaders(config) {
        // Additional CSS classes
        const row_styles = {kinkoid_power: '2rem', girl_power: '2.2rem', number_of_bulbs: '0.9rem'}
        for (const [clazz, min_width] of Object.entries(row_styles)) {
            // this.insertRule(`.league_table .data-row .head-column[column="${clazz}"] {display: flex; align-items: center; justify-content: center}`);
            this.insertRule(`.league_table .data-row .data-column[column="${clazz}"] {min-width: ${min_width}}`);
        }

        const columns = this.all_new_columns.filter(c => config[c]);

        const headers = {
            kinkoid_power: `<span>${GT.design.caracs_sum}</span>`,
            girl_power: `<span>${GT.design.total_power}</span>`, // <span class="upDownArrows_mix_icn">
            number_of_bulbs: '<span class="scrolls_legendary_icn"></span>',
        }

        $(`div.league_table div.head-row div.head-column[column=${this.anchor_column}]`).after(
            columns.map(c => `<div class="data-column head-column" column="${c}">${headers[c]}</div>`).join('')
        );

    }

    showAdditionalTableColumns(config) {
        this.insertRule(`.active_skill {color: red; text-shadow: 1px 1px 0 #000, -1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000;}`);

        const context = this;

        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const opponent = window.opponents_list[index];
                const columns = context.all_new_columns.filter(c => config[c]);

                $(this).find(`div.data-column[column=${context.anchor_column}]`).after(
                    columns.map(c => `<div class="data-column" column="${c}"><div class="${context.tableColumnClass(c, opponent)}">${opponent[c]}</div></div>`).join('')
                );
            })
    }

    tableColumnClass(column, opponent) {
        if (!column.includes('bulb')) {
            return '';
        }

        const clazz = 'active_skill'; // or active_skills_icn

        const active_skills = opponent.player.team.girls[0].skill_tiers_info[5];
        return active_skills && active_skills.skill_points_used > 0 ? clazz : '';
    }

    showMaxPointsTooltip() {
        const data = LeagueScoutModule.getCurrent();

        if (!data || !data.snapshots.length) {
            return;
        }

        $('.league_table .body-row').each(function (idx) {
            const opponent = opponents_list[idx];
            const opponent_id = parseInt(opponent.player.id_fighter);

            const points = data.snapshots.map(d => d.snapshot.find(p => p.id == opponent_id).points);

            const remainder = points[0] % 25;
            var lost_points = (remainder == 0 ? 0 : 25 - remainder);

            for (let i = 0; i < points.length - 1; i+=1) {
                const diff = points[i+1] - points[i];
                const remainder = diff % 25;
                lost_points += (remainder == 0 ? 0 : 25 - remainder);
            }

            const element = $(this).find('.data-column[column=player_league_points]');
            element.attr('tooltip', `Lost points: ${lost_points}`);
        });
    }

    detectReallyExpiredBoosers() {
        const data = LeagueScoutModule.getCurrent();

        if (!data || !data.snapshots.length) {
            return;
        }

        $('.league_table .body-row').each(function (idx) {
            const opponent = opponents_list[idx];
            const opponent_id = parseInt(opponent.player.id_fighter);

            const is_currently_boosted = opponent.boosters.some(b => b.expiration > 0);
            if (is_currently_boosted) {
                return;
            }

            const snapshot_data = data.snapshots.map(d => d.snapshot.find(p => p.id == opponent_id)).reverse();
            const boosted_data = snapshot_data.find(p => p.boosters && p.boosters.length >= 3);
            if (!boosted_data) {
                return;
            }

            if (
                (opponent.player.damage * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.damage &&
                (opponent.player.defense * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.defense &&
                (opponent.player.remaining_ego * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.ego &&
                (opponent.player.chance * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.chance
            ) {
                const element = $(this).find('.data-column[column=boosters]');
                element.addClass('active_skill');
            }
        });
    }

    detectInflatedPower(take_n = 30) {
        const data = LeagueScoutModule.getCurrent();

        if (!data || !data.snapshots.length) {
            return;
        }

        $('.league_table .body-row').each(function (idx) {
            const opponent = opponents_list[idx];
            const opponent_id = parseInt(opponent.player.id_fighter);
            const current_power = opponent.player.team.total_power;

            const snapshot_data = data.snapshots.map(d => d.snapshot.find(p => p.id == opponent_id));
            const latest_power = snapshot_data.map(x => x.power).filter(x => x).slice(-1 * take_n);
            const lowest_power = Math.min(...latest_power);

            if (current_power > lowest_power) {
                const element = $(this).find('.data-column[column=team] span.team-power');
                element.addClass('active_skill');
                element.attr('tooltip', `Previously: ${lowest_power}`);
            }
        });
    }

    showPlayersPlacementBadge(config) {
        if (!config.load_player_data) {
            return;
        }

        // Additional CSS classes
        this.insertRule('.badge.top1 {background-color:#ec0039}');
        this.insertRule('.badge.top1::after {content:"1"}');

        for (let i = 2; i <= 4; i++) {
            // this.insertRule(`.badge.top${i} {background-color:#8e36a9}`);
            this.insertRule(`.badge.top${i} {background:var(--legendary-bg);background-size:cover}`);
            this.insertRule(`.badge.top${i}::after {content:"${i}"}`);
        }

        const context = this;

        $('.league_table .body-row').each(function (idx) {
            const opponent = opponents_list[idx];
            if (opponent.best_placement != undefined) {
                context.updatePlayerPlacementBadge($(this), opponent);
            }
        });
    }

    addPlayerSelectHandler(config) {
        if (!config.load_player_data) {
            return;
        }

        // Remove the go_pre_battle class to allow users to select the row (inspired by Leagues++)
        $('.league_table .data-column[column=can_fight] .go_pre_battle').removeClass('go_pre_battle');

        $('.league_table .body-row').on('click', function() {
            const element_nickname = $(this).find('.data-column[column=nickname] span.nickname')
            const player_id = element_nickname.attr('id-member');

            const opponent = window.opponents_list.find(x => parseInt(x.player.id_fighter) == player_id);
            if (opponent.best_placement != undefined) {
                return false;
            }

            window.$.post({
                url: '/ajax.php',
                data: {
                    action: 'fetch_hero',
                    id: 'profile',
                    preview: false,
                    player_id: parseInt(player_id),
                },
                success: (data) => {}
            });

            return false;
        });
    }

    updatePlayerPlacementBadge(row, player_data) {
        const nicknameElement = row.find('div.data-column[column=nickname]');

        var badgeContainer = nicknameElement.find('.badge-container');
        if (!badgeContainer.length) {
            badgeContainer = $('<div class="badge-container" />').appendTo(nicknameElement);
        }

        // best placement indicator next to the nickname
        badgeContainer.html(this.createBestPlacementBadge(player_data));
    }

    createBestPlacementBadge(player) {
        if (player.best_placement < 1 || player.best_placement > 4) {
            return ''
        }

        const clazz = `top${player.best_placement}`;
        return `<span class="best-placement"><span class="scriptLeagueInfoIcon badge ${clazz}"></span>${player.placement_count}</span>`;
    }

    makeCompatibleWithLeaguePlusPlus() {
        HHPlusPlus.Helpers.doWhenSelectorAvailable('div#leagues div.league_buttons a#change_team', () => {
            // Remove the avatars
            $('div.league_table div.data-row div.data-column[column=nickname] div.square-avatar-wrapper').remove();

            // Hide row when opponent has been fought
            const context = this;
            $('body').on('DOMSubtreeModified', '.league_table .body-row .data-column[column=match_history_sorting]', function() {
                context.hideUnhideRow($(this).parent('div.body-row'), context.isHideOpponents());
            });
        });
    }

    hideUnhideRow(row, hide) {
        const results = row.find('div.data-column[column=match_history_sorting]').find('div[class!="result "]').length;
        const fought_all = results == 3;
        if (fought_all && hide) {
            row.hide();
        } else if (fought_all && !hide) {
            row.show();
        }
    }

    isHideOpponents() {
        const filter = JSON.parse(storage.getItem(HHPLUSPLUS_OPPONENT_FILTER)) || {fought_opponent: false};
        return filter.fought_opponent;
    }
}

class PrebattleFlightCheckModule extends MyModule {
    constructor () {
        const baseKey = 'prebattleFlightCheck'
        const configSchema = {
            baseKey,
            default: true,
            label: `Run team and equipment checks before league battles`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('leagues-pre-battle') || HHPlusPlus.Helpers.isCurrentPage('leagues')}

    run() {
        if (this.hasRun || !this.shouldRun()) {return}

        HHPlusPlus.Helpers.defer(() => {
            if (HHPlusPlus.Helpers.isCurrentPage('leagues')) {
                $(document).ajaxComplete((evt, xhr, opt) => {
                    if (xhr.status == 200 && ~opt.url.search(/\/leagues-pre-battle.html\?id_opponent=\d+/)) {
                        const hero = window.Hero ?? shared.Hero;
                        const me = opponents_list.find(p => parseInt(p.player.id_fighter) == hero.infos.id);
                        const themes = me.player.team.theme_elements.map(x => x.type);

                        this.checkMythicEquipment(themes);
                    }
                });
            }

            if (HHPlusPlus.Helpers.isCurrentPage('leagues-pre-battle')) {
                HHPlusPlus.Helpers.doWhenSelectorAvailable('div.player-panel div.player-team', () => {
                    const synergies = JSON.parse($('div.player-panel div.player-team div.icon-area').attr('synergy-data'));
                    const themes = synergies.filter(x => x.team_girls_count >=3).map(x => x.element.type);

                    this.checkMythicEquipment(themes);
                });
            }
        });

        this.hasRun = true;
    }

    checkMythicEquipment(themes_or_empty) {
        // Additional CSS classes
        this.insertRule(`.slot.size_xxs {width:1.5rem;height:1.5rem;-webkit-border-radius:.2rem;-moz-border-radius:.2rem;border-radius:.2rem}`);

        const me = EquipmentCollector.getBestMythic();
        const equipment_themes = me.map(x => x.resonance_bonuses.theme.identifier || 'balanced');

        const themes = themes_or_empty.length ? themes_or_empty : ['balanced'];

        const has_matching_me = themes.some(t => equipment_themes.includes(t));
        if (has_matching_me) {
            const tooltip = "You have a perfect mythic equipment for your team in your inventory.";
            $('div.opponent div.player_details').append(
                `<div class="slot size_xxs mythic random_equipment mythic" rarity="mythic" tooltip="${tooltip}">
                    <span class="mythic_equipment_icn"></span>
                </div>`
            );
        }
    }
}

class GirlPreviewModule extends MyModule {
    constructor () {
        const baseKey = 'girlPreviewFilters'
        const configSchema = {
            baseKey,
            default: true,
            label: `Girl preview`,
            subSettings: [
                {
                    key: 'preview_girl_pose',
                    label: 'Uncensor girl pose preview',
                    default: false
                },
            ]
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return true;}

    run ({preview_girl_pose}) {
        if (this.hasRun || !this.shouldRun()) {return}

        HHPlusPlus.Helpers.defer(() => {
            if (preview_girl_pose) {
                this.previewGirlPose();
            }
        });

        this.hasRun = true;
    }

    previewGirlPose() {
        const observer = new MutationObserver(() => {
            HHPlusPlus.Helpers.doWhenSelectorAvailable('#girl_preview_popup', () => {
                $("div.pose-preview_wrapper").removeClass("locked");
                $("span.preview-locked_icn").remove();
            });
        })
        observer.observe($('#common-popups')[0], {childList: true});
    }
}

class SeasonalEventModule extends MyModule {
    constructor () {
        const baseKey = 'seasonalEvent'
        const configSchema = {
            baseKey,
            default: true,
            label: `Seasonal event`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('seasonal');}

    run () {
        if (this.hasRun || !this.shouldRun()) {return}

        HHPlusPlus.Helpers.defer(() => {
            for (const e of [50, 100, 250, 500, 1000]) {
                this.insertRule(`.badge.top${e} {background-color:#333; text-align:center}`);
                this.insertRule(`.badge.top${e}::after {content:"${e}"}`);
                this.insertRule('.scriptLeagueInfoIcon {display: inline-block;height: 16px;width: 32px;font-size: 10px;border-radius: 5px;margin-left: 6px;margin-right: 2px;text-shadow: 0 0 1px #000;-moz-transform: rotate(0.05deg);');
                this.insertRule('.leaderboard-placement {font-size: 12px;text-shadow:1px 1px 0 #000}');
            }

            HHPlusPlus.Helpers.onAjaxResponse(/action=leaderboard&feature=seasonal_event_top/, this.rankingBadges);
        });

        this.hasRun = true;
    }

    rankingBadges(leaderboard) {
        HHPlusPlus.Helpers.doWhenSelectorAvailable('.ranking-timer', () => {

            const xd = [50, 100, 250, 500, 1000].map(e => {
                const diff = leaderboard.leaderboard[e - 1].potions - leaderboard.hero_data.potions + 1;
                return `<span class="leaderboard-placement"><span class="scriptLeagueInfoIcon badge top${e}"></span>${diff}</span>`;
            }).join('');

            $("div.ranking-timer").append(xd);
        });
    }
}

setTimeout(() => {
    const {hhPlusPlusConfig, HHPlusPlus, location} = window;

    if (!$) {
        console.log('No jQuery found. Probably an error page. Ending the script here')
        return;
    } else if (!hhPlusPlusConfig || !HHPlusPlus) {
        console.log("HH++ is not available");
        return;
    } else if (location.pathname === '/' && (location.hostname.includes('www') || location.hostname.includes('test'))) {
        console.log("iframe container, do nothing");
        return;
    }

    // collectors
    EquipmentCollector.collect();
    LeaguePlayersCollector.collect();

    // modules
    const modules = [
        new LeagueScoutModule(),
        new LeagueTableModule(),
        new PrebattleFlightCheckModule(),
        new GirlPreviewModule(),
        new SeasonalEventModule(),
    ]

    // register our own window hooks
    window.HHPlusPlusPlus = {
        exportLeagueData: LeaguePlayersCollector.export,
        clearLeagueData: LeaguePlayersCollector.clear,
    };

    hhPlusPlusConfig.registerGroup({
        key: '430i',
        name: '430i\'s Scripts'
    })

    modules.forEach(module => hhPlusPlusConfig.registerModule(module))
    hhPlusPlusConfig.loadConfig()
    hhPlusPlusConfig.runModules()

    HHPlusPlus.Helpers.runDeferred()
}, 1)