Jump to content

Community Scripts [Links in OP]


DvDivXXX
 Share

Recommended Posts

18 minutes ago, 430i said:

The problem with the doubled pins and boosters was caused by erroneously calling zoo's s

Ah nice thanks, jep that works here as well on desktop 👍.

21 minutes ago, 430i said:

My script checks the league table every time you open the page and if there has been any change (even by a single point) compared to last time you opened the page, it will create a snapshot and store it in the browser's local storage.

Ah, so you can follow/track changes between snapshots? Interesting feature indeed, but at least for me a bit too much (also since I cannot reasonably aim for any high score). Probably makes sense to make this an configurable as well.

23 minutes ago, 430i said:

I might make it configurable at the end, so everyone can decide which power column should be shown, if any.

Indeed, surely the best way.

25 minutes ago, 430i said:

The way to do it would be to load the profile when selecting a player row in the table. That's the same way the booster detection script worked, so it should be fairly safe from automation point if view. It would still require manual effort - selecting all opponents one by one, but at least it will remove the need for the reload column.

Ah right with L++, where a row can be selected, this is a good way to combine it with loading profile info. About "automation" when loading all player info at once: Not sure if such is problematic, as it is not about automation fights, collecting resources, equipping girls or any such, but only about collecting data? But better be safe then sorry, so probably something to check back with KK before implementing it.

  • Like 1
Link to comment
Share on other sites

  • Moderator
7 hours ago, Der DinX said:

@430i this looks funny 😉

image.png.8789187dcf1db28cff671f6ea8da872a.png

Thanks, I was going to post a similar screenshot. There are a few conflicts at least visually with all three scripts active at the same time (HH++, League++ and HH+++). It's not ideal when you get these weird scrambled images instead of the boosters and/or scores at times, but typically refreshing opponents or reopening the league page fixes it for me.

Another thing I've noticed and I'm not even sure if it's the base (broken) feature or any of the scripts or just another problem the patch introduced is that after fighting an opponent, I don't see them as fought and my scores on them until I refresh the page. In the same line of thought, if I select an opponent to fight but I don't have enough tokens, after making the refill I still have the x1 and/or x3 button(s) grayed out until I refresh as well.

These are really minor things and greatly outweighed by all the convenience and playability the scripts provide us despite the vanilla game's patch, though. If this can be even further fine-tuned and improved, awesome, but I'm really really satisfied with what we have right now.

Eventually I expect Zoo (and Tom) will integrate, emulate and/or compile all of the useful options added by MM, Zoo, and 430i recently right into the main HH++ script(s). The more concurrent scripts we run at once, the higher the chance some combinations of them will create weird visual artifacts or glitches.

EDIT: I'm not comfortable manually editing any of the scripts. I'm thankful for the changes suggested by both @430i and @Horsting above, but I'd rather have a full revised script I can copy-paste as is. I'm afraid I'll break more things than I'll fix if I start deleting or adding lines myself. :$

Edited by DvDivXXX
updated
  • Like 1
Link to comment
Share on other sites

36 minutes ago, DvDivXXX said:

Another thing I've noticed and I'm not even sure if it's the base (broken) feature or any of the scripts or just another problem the patch introduced is that after fighting an opponent, I don't see them as fought and my scores on them until I refresh the page. In the same line of thought, if I select an opponent to fight but I don't have enough tokens, after making the refill I still have the x1 and/or x3 button(s) grayed out until I refresh as well.

AFAIR the first was mentioned by MM already: When you use the League++ fight buttons, the data about which fight ended with which amount of points is not available to the script immediately, but only after refreshing the page. Since without League++, the battle is done on the dedicated battle page, and hence a reload is implied, this would then be a limitation of this feature skipping the battle page. That the buttons remain greyed out when doing refills should be for a similar reason: The data is not updated until page reload.

It should be at least possible to scrape the info about how many fights were lost (if any) from the rewards prompt, and then colour the related amount of those squares red or green, but without the points, triggered by clicking "Ok". But not sure whether this is worth the effort?

Theoretically a script could also scrape info from the refills screen to immediately make the x1/x3 buttons available again. But opposed to other scripts, MM's script is bound to the particular league sub frame, which I find quite beautiful to prevent it from being loaded unnecessarily on any other page. The downside is that it is likely not able to "see" the refill page, or what happens on it. Also if the script was loaded site wide, I am not sure whether clicking something on one sub frame would be able to trigger a change in another one 🤔. That goes beyond my JavaScript knowledge.

Edited by Horsting
  • Thanks 1
Link to comment
Share on other sites

2 hours ago, DvDivXXX said:

after making the refill I still have the x1 and/or x3 button(s) grayed out until I refresh as well

1 hour ago, Horsting said:

That the buttons remain greyed out when doing refills should be for a similar reason: The data is not updated until page reload.

I'd say that is a game thing, it is similar with Villain fights.
Depending on where you buy the refill it will work or not.

If you refill CP at the top frame, the perform button will stay gray, but if you use the perform button to refill, it will work.

 

1 hour ago, Horsting said:

AFAIR the first was mentioned by MM already

Yes indeed, it was.

 

 

2 hours ago, DvDivXXX said:

I'm afraid I'll break more things than I'll fix if I start deleting or adding lines myself. :$

You dare 😁

No really, actually you can't make any real damage.
If it doesn't work anymore afterwards, just re-install it. (And try again)

Edited by Der DinX
  • Like 1
  • Thanks 1
Link to comment
Share on other sites

  • Moderator

Alright, I'm getting close to the endgame in the current league, so I could really use more clarity.

To manually improve @430i's script, to be clear, I need to:

  1. Delete the following lines?
    On 8/12/2023 at 3:23 PM, 430i said:
    // Re-trigger a refiltering of the leagues table in zoo's script
            $(document).trigger('league:table-sorted');
  2. Add these two chunks @Horsting provided? But where? Do they replace some existing lines or should I just add them at the end?
    Quote
    $('.league_table .body-row .data-column[column="reload"] button').on('click', function() {
                const player_id = $(this).attr('id-member');
    
                window.$.post({
                    url: '/ajax.php',
                    data: {
                        action: 'fetch_hero',
                        id: 'profile',
                        preview: false,
                        player_id: parseInt(player_id),
                    },
                    success: (data) => {}
                })
                return false  <------------------------ This one, to abort any following click action!
            });

    &

    Quote
    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>`
        }

EDIT:

Right? 🙏 (Again if a good Samaritan could just post the whole updated script in one spoiler or quote for me to copy-paste, it would be really sweet. Touching lines of code stresses me out.)

Also, could anyone explain to me what these little extra numbers on some of my opponents mean?

image.png

Edited by DvDivXXX
updated
Link to comment
Share on other sites

430i's script with major visual fix by 430i and my little tweaks for the "load opponent info", download table and clear data buttons:

Spoiler
// ==UserScript==
// @name            Hentai Heroes+++
// @description     Few QoL improvement and additions for competitive PvP players.
// @version         0.17.0
// @match           https://*.hentaiheroes.com/*
// @match           https://nutaku.haremheroes.com/*
// @run-at          document-body
// @grant           none
// @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";
const EQUIPMENT_FAVORITE_KEY = EQUIPMENT_KEY + ".Favorite";
const TEAMS_BASE_KEY = LS_CONFIG_NAME + ".Teams";
const TEAMS_ALL_KEY = TEAMS_BASE_KEY + ".All";
const TEAMS_CURRENT_ID_KEY = TEAMS_BASE_KEY + ".CurrentId";

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

// 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.collectPlayerEquipment();
                EquipmentCollector.collectBestMythicEquipment();
            }, 250);
        });
    }

    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),
            };
        });

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

    static collectBestMythicEquipment() {
        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('tower-of-fame')) {
            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 me() {
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const player = window.opponents_list[r];
            if (player.player.nickname == Hero.infos.name) {
                return player;
            }
        }

        window.popup_message("Could not find myself in the league table.");
    }

    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 TeamsCollector {
    static collect() {
        if (!HHPlusPlus.Helpers.isCurrentPage('teams')) {
            return;
        }

        HHPlusPlus.Helpers.defer(() => {
            // action=select_team&id_team=3680477&battle_type=leagues
            HHPlusPlus.Helpers.onAjaxResponse(/action=select_team&id_team=\d+&battle_type=leagues/, TeamsCollector.collectSelectedTeamFromAjaxResponse);
            TeamsCollector.collectTeams();
        });
    }

    static collectSelectedTeamFromAjaxResponse(response, opt) {
        // Figure out the team id from the request params
        const params = new URLSearchParams(opt.data);
        const team_id = params.get("id_team");

        // Only store the team id to be able to easily refresh the team information when boosters expire or new ones are applied.
        window.localStorage.setItem(TEAMS_CURRENT_ID_KEY, JSON.stringify(parseInt(team_id)));
    }

    static collectTeams() {
        // Create a team map - team_id: team
        const teams = Object.fromEntries(Object.values(teams_data).filter(t => !t.locked).map(t => [parseInt(t.id_team), t]));
        storage.setItem(TEAMS_ALL_KEY, JSON.stringify(teams));
    }

    static getCurrent() {
        const team_id = JSON.parse(window.localStorage.getItem(TEAMS_CURRENT_ID_KEY));
        const all_teams = TeamsCollector.getAll();

        if (team_id && all_teams) {
            return all_teams[team_id];
        }

        window.popup_message(
            `Could not retrieve current team. Team ID: ${team_id}, all teams: ${Boolean(all_teams)}.
            Please open the team selection page and re-select your team.`
        );
    }

    static getAll() {
        return JSON.parse(window.localStorage.getItem(TEAMS_ALL_KEY)) || {};
    }
}

class LeagueSimulator {
    extract(me, other) {
        // This largely uses the upsteam 'League' class (thanks zoop0kemon), but tweaks it to the new input data and
        // takes the themes into account to caculate defence reduction and counter bonuses.
        const {
            team: playerTeam
        } = me.player;

        var {
            ego: playerEgo,
            damage: playerAtk,
            defense: playerDef,
            chance: playerCrit,
        } = playerTeam.caracs;

        // MB2 - All Mastery, MB8 - Leagues Mastery
        const playerHasMasteryMythicBooster = me.boosters.some(b => ['MB2', 'MB8'].includes(b.item.identifier))

        const playerElements = playerTeam.theme_elements.map(({type}) => type);
        const playerWeaknessElements = playerTeam.theme_elements.map(({weakness}) => weakness);
        const playerSynergies = playerTeam.synergies;
        const playerBonuses = {
            critDamage: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'fire'),
            critChance: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'stone'),
            healOnHit: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'water'),
            reduceDefence: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'sun'),
        }

        const {
            nickname: opponentName,
            team: opponentTeam,
        } = other.player;

        var {
            ego: opponentEgo,
            chance: opponentCrit,
            damage: opponentAtk,
            defense: opponentDef,
        } = opponentTeam.caracs

        // MB2 - All Mastery, MB8 - Leagues Mastery
        // The opponent boosters does not currently contain the mastery boosters, but include it in the calculation
        // just in case Kinkoid messes something up.
        const opponentHasMasteryMythicBooster = other.boosters.some(b => ['MB2', 'MB8'].includes(b.item.identifier))

        const opponentTeamMemberElements = [];
        [0,1,2,3,4,5,6].forEach(key => {
            const teamMember = opponentTeam.girls[key]
            if (teamMember && teamMember.element) {
                opponentTeamMemberElements.push(teamMember.element)
            }
        })
        const opponentElements = opponentTeam.theme_elements.map(({type}) => type)
        const opponentWeaknessElements = opponentTeam.theme_elements.map(({weakness}) => weakness);

        const opponentSynergies = opponentTeam.synergies
        const teamGirlSynergyBonusesMissing = opponentSynergies.every(({team_girls_count}) => !team_girls_count)
        let counts
        if (teamGirlSynergyBonusesMissing) {
            // Open bug, sometimes opponent syergy data is missing team bonuses, so we need to rebuild it from the team
            counts = opponentTeamMemberElements.reduce((a,b)=>{a[b]++;return a}, {
                fire: 0,
                stone: 0,
                sun: 0,
                water: 0,
                nature: 0,
                darkness: 0,
                light: 0,
                psychic: 0
            })
        }

        const opponentBonuses = {
            critDamage: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'fire', teamGirlSynergyBonusesMissing, counts),
            critChance: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'stone', teamGirlSynergyBonusesMissing, counts),
            healOnHit: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'water', teamGirlSynergyBonusesMissing, counts),
            reduceDefence: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'sun', teamGirlSynergyBonusesMissing, counts),
        }

        const dominanceBonuses = HHPlusPlus.SimHelpers.calculateDominationBonuses(playerElements, opponentElements);

        const counterElementsAtkEgo = ['fire', 'nature', 'stone', 'sun', 'water'];
        const counterElementsHarmony = ['darkness', 'light', 'psychic'];

        const playerCountersOpponentAtkEgo = opponentWeaknessElements.filter(e => counterElementsAtkEgo.includes(e) && playerElements.includes(e)).length;
        const opponentCountersPlayerAtkEgo = playerWeaknessElements.filter(e => counterElementsAtkEgo.includes(e) && opponentElements.includes(e)).length;

        const playerCountersOpponentHarmony = opponentWeaknessElements.filter(e => counterElementsHarmony.includes(e) && playerElements.includes(e)).length;
        const opponentCountersPlayerHarmony = playerWeaknessElements.filter(e => counterElementsHarmony.includes(e) && opponentElements.includes(e)).length;

        // Attack & Ego
        if (playerCountersOpponentAtkEgo) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.1 * playerCountersOpponentAtkEgo;
            playerAtk *= coef;
            playerEgo *= coef;
        }
        if (opponentCountersPlayerAtkEgo) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.1 * opponentCountersPlayerAtkEgo;
            opponentAtk *= coef;
            opponentEgo *= coef;
        }

        // Mastery boosters
        if (playerHasMasteryMythicBooster) {
            playerAtk *= 1.15;
        }
        if (opponentHasMasteryMythicBooster) {
            opponentAtk *= 1.15;
        }

        // Defence
        // Our opponent's defence has already been reduced when creating the league table
        // opponentDef *= (1 - playerBonuses.reduceDefence);
        playerDef *= (1 - opponentBonuses.reduceDefence);

        // Harmony
        if (playerCountersOpponentHarmony) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.2 * playerCountersOpponentHarmony;
            playerCrit *= coef;
        }
        if (opponentCountersPlayerHarmony) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.2 * opponentCountersPlayerHarmony;
            opponentCrit *= coef;
        }

        const player = {
            hp: playerEgo,
            dmg: playerAtk - opponentDef,
            critchance: HHPlusPlus.SimHelpers.calculateCritChanceShare(playerCrit, opponentCrit) + dominanceBonuses.player.chance + playerBonuses.critChance,
            bonuses: {...playerBonuses, dominance: dominanceBonuses.player},
            theme: playerElements,
        }
        const opponent = {
            hp: opponentEgo,
            dmg: opponentAtk - playerDef,
            critchance: HHPlusPlus.SimHelpers.calculateCritChanceShare(opponentCrit, playerCrit) + dominanceBonuses.opponent.chance + opponentBonuses.critChance,
            name: opponentName,
            bonuses: {...opponentBonuses, dominance: dominanceBonuses.opponent},
            theme: opponentElements,
        }

        return {player, opponent}
    }
}

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('tower-of-fame')}

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

        $(document).on('league:rollover', () => {
            const data = JSON.parse(storage.getItem(CURRENT_LEAGUE_SNAPSHOT_KEY)) || [];

            storage.setItem(PREVIOUS_LEAGUE_SNAPSHOT_KEY, JSON.stringify(data));
            storage.removeItem(CURRENT_LEAGUE_SNAPSHOT_KEY);
        })

        HHPlusPlus.Helpers.defer(() => {
            // read and store data
            const playerData = this.readPlayerData();
            const snapshot = this.createSnapshot(playerData);
            this.storeSnapshot(snapshot);

            // 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 = player.id_player;
            const rank = player.place;
            const name = player.player.nickname;
            const country = player.country;
            const level = parseInt(player.player.level);
            const points = parseInt(player.player_league_points);
            const elements = player.player.team.theme;

            data.push({id, name, rank, level, elements, points, country});
        }

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

        return data;
    }

    createSnapshot(playerData) {
        const numPlayers = playerData.length;
        const currentDate = new Date(window.server_now_ts * 1000);
        const leagueEndDate = new Date(window.server_now_ts * 1000 + window.season_end_at * 1000);

        return {
            date: currentDate,
            league_end: leagueEndDate,
            num_players: numPlayers,
            player_data: playerData,
        }
    }

    storeSnapshot(snapshot) {
        const data = JSON.parse(storage.getItem(CURRENT_LEAGUE_SNAPSHOT_KEY)) || [];

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

        data.push(snapshot);
        storage.setItem(CURRENT_LEAGUE_SNAPSHOT_KEY, JSON.stringify(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 = JSON.parse(storage.getItem(what)) || [];

            const separator = ","
            const columns = ["date", "player_id", "player_name", "player_rank", "player_points"];
            const values = data.flatMap((e) => e.player_data.map((p) => [e.date, p.id, p.name, p.rank, p.points].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);
        });
    }
}

class LeagueTableModule extends MyModule {
    constructor () {
        const baseKey = 'leagueTable'
        const configSchema = {
            baseKey,
            default: true,
            label: `Extend league table with additional opponents' information`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('tower-of-fame')}

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

        HHPlusPlus.Helpers.defer(() => {
            this.showPlayerLiveData();
            this.extendLeagueDataModel();

            HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_buttons_block', () => {
                this.reinitLeaguesTable();
                this.makeCompatibleWithLeaguePlusPlus();
            });

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

        this.hasRun = true;
    }

    showPlayerLiveData() {
        // Show the live data for the player: theme and stats (atk, ego, def, harmony)
        const player = LeaguePlayersCollector.me();
        const team = TeamsCollector.getCurrent();

        if (!team) {
            return;
        }

        // Update the player's data
        player.player.team = team;

        // Update the opponents' data (only defence)
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const opponent = window.opponents_list[r];

            if (opponent == player) {
                continue;
            }

            // Defence
            const reduceDefence = HHPlusPlus.SimHelpers.findBonusFromSynergies(player.player.team.synergies, 'sun');
            opponent.player.team.caracs.defense *= (1 - reduceDefence);
        }
    }

    calculateExpectedPoints(me, other) {
        const sim = new LeagueSimulator();
        const {player, opponent} = sim.extract(me, other);

        const simulator = new HHPlusPlus.Simulator({player, opponent, highPrecisionMode: false, logging: false})
        const result = simulator.run()

        return Object.entries(result.points).map(([pts, coef]) => parseInt(pts) * coef).reduce((acc, a) => acc + a, 0);
    }

    extendLeagueDataModel() {
        const me = LeaguePlayersCollector.me();
        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;

            const expected_points = this.calculateExpectedPoints(me, player);

            player.expected_points = expected_points;
            player.best_placement = best_placement;
            player.placement_count = placement_count;
        }
    }

    // This is mostly copy-pasted from the native 'initLeaguesTable' method with a couple additional changes:
    //  - display best rankings next to the username in the 'name' column
    //  - display girl power in the 'power' column and not whatever crap Kinkoid came up with
    //  - display the theme instead of the synergy popup in the 'team' column
    reinitLeaguesTable() {
        // Additional CSS classes
        this.insertRule(`.scriptLeagueInfoIcon.top1 {background-color:var(--var-color-mythic)}`);
        this.insertRule('.scriptLeagueInfoIcon.top1::after {content:"1"}');
        this.insertRule('.league_table .data-list .body-row .data-column[column="team"] span {width: 20px; height: 20px; background-size: 20px}');
        // this.insertRule('.league_table .data-list .body-row .data-column[column="expected_points"] {filter: blur(5px); -webkit-filter: blur(5px);}');
        this.insertRule('.league_table .data-list .body-row .data-column[column="reload"] button {padding: 2px 5px}');

        // Recreate the new settings struct with all additional goodies
        const settings = {
            sorting: true,
            sorting_callback: this.addSortCallback,
            column_display_names: {
                place: GT.design.Rank,
                nickname: GT.design.Name,
                team: GT.design.team,
                level: GT.design.Lvl,
                power: GT.design.caracs_sum,
                player_league_points: GT.design.leagues_points,
                match_history_sorting: GT.design.Challenges,
                boosters: GT.design.leagues_stats_and_boosters,
                expected_points: 'E[X]',
                can_fight: GT.design.challenge,
                reload: '↻'
            },
            visible_columns: [
                'place',
                'nickname',
                'level',
                'player_league_points',
                'power',
                'expected_points',
                'match_history_sorting',
                'team',
                'boosters',
                'can_fight',
            ],
            head_column_html: '<span>[column_value]<span class="upDownArrows_mix_icn"></span></span>',
            body_column_html: {
                nickname: this.createLeagueColumnNickname,
                level: (row) => row.player.level,
                power: (row) => row.player.team.total_power.toFixed(),
                team: this.createLeagueColumnTeam,
                player_league_points: (row) => number_reduce(row.player_league_points),
                match_history_sorting: this.createChallengesColumn,
                expected_points: (row) => row.expected_points.toFixed(2),
                boosters: this.createStatsAndBoostersColumn,
                can_fight: this.createChallengeButtonColumn,
                reload: this.createReloadButtonColumn,
                // 'body_column_html' is passed as 'this' to all html functions,
                // so we need to include a couple of utility functions in here
                canFight: this.canFight,
                createBestPlacementBadge: this.createBestPlacementBadge,
            }
        };

        const isReloadVisible = opponents_list.some(o => o.best_placement == undefined);
        if (isReloadVisible) {
            settings.visible_columns.push('reload');
        }

        initDataList(opponents_list, '#leagues .league_table', settings);

        $('#leagues .league_table').getNiceScroll().remove();
        $('#leagues .league_table').niceScroll({horizrailenabled: false});
        this.addSortCallback();

        $('.league_table .body-row .data-column[column="reload"] button').on('click', function() {
            const player_id = $(this).attr('id-member');

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

    addSortCallback() {
        // Native callback implementation
        $(`.data-list [id-member="${Hero.infos.id}"]`).parent().parent().addClass('player-row')

        // Re-trigger a refiltering of the leagues table in zoo's script
        $(document).trigger('league:table-sorted');
    }

    // Native method, beatified
    canFight(oponent_data) {
        if (Hero.infos.id == oponent_data.player.id_fighter || !oponent_data.hasOwnProperty('match_history')) {
            return false
        }

        return oponent_data.match_history[oponent_data.player.id_fighter].some((battle) => !battle);
    }

    createBestPlacementBadge(row) {
        if (row.best_placement == 1) {
            return `<span class="best-placement"><span class="scriptLeagueInfoIcon top1"></span>${row.placement_count}</span>`;
        } else if (row.best_placement >= 2 && row.best_placement <= 4) {
            return `<span class="best-placement"><span class="scriptLeagueInfoIcon top4"></span>${row.placement_count}</span>`;
        }

        return '';
    }

    // Native method, bodified to include best placement badge
    createLeagueColumnNickname(row) {
        // best placement indicator next to the nickname
        const placement_badge = this.createBestPlacementBadge(row);

        return `<div class="square-avatar-wrapper" ><img src="${row.player.ico}"/></div><span class="country country-${row.country.toLowerCase()}" tooltip hh_title="${row.country_text}"></span><span class="nickname" id-member="${row.player.id_fighter}">${row.player.nickname}</span>${placement_badge}`;
    }

    createLeagueColumnTeam(row) {
        // show theme icons instead of syngergy tooltip
        const themes = (row.player.team.theme || "balanced").split(',');
        const theme_icons = themes.map(t => `<span class="${t}_element_icn ${t}_theme_icn"></span>`);
        return theme_icons.join('');
    }

    // Native method, beatified
    createChallengesColumn(row_data) {
        if (Hero.infos.id == row_data.player.id_fighter || !row_data.hasOwnProperty('match_history')) {
            return '-';
        }

        return row_data.match_history[row_data.player.id_fighter]
                .map(challenge => {
                    const challenge_class = challenge ? challenge.attacker_won : '';
                    const challenge_text = challenge ? challenge.match_points : '';
                    return '<div class="result ' + challenge_class + '">' + challenge_text + '</div>';
                })
                .join('');
    }

    // Native method, beatified
    createStatsAndBoostersColumn(row_data) {
        const is_player = Hero.infos.id == row_data.player.id_fighter;

        const stats_html = buildPlayerStats(
            row_data.player.team.caracs.damage,
            row_data.player.team.caracs.defense,
            row_data.player.team.caracs.ego,
            row_data.player.team.caracs.chance,
            is_player,
            true,
        );

        const boosters_html = row_data.boosters.map((booster) => newReward.slot(booster, 'xs')).join('');

        return stats_html + '<div class="boosters">' + boosters_html + '</div>'
    }

    // Native method, beatified
    createChallengeButtonColumn(row_data) {
        return this.canFight(row_data) ? '<a href="/leagues-pre-battle.html?id_opponent=' + row_data.player.id_fighter + '" class="go_pre_battle blue_button_L">' + GT.design.daily_goals_go + '</a>' : '';
    }

    createReloadButtonColumn(row) {
        return row.best_placement == undefined ? `<button class="blue_button_L" id-member="${row.player.id_fighter}">↻</button>` : '';
    }

    onPlayerProfile(data) {
        const id = data.id;

        const idx = window.opponents_list.findIndex(x => parseInt(x.player.id_fighter) == id);
        const opponent = window.opponents_list[idx];

        const row = $($('div.league_table div.body-row').get(idx));

        // best placement indicator next to the nickname
        row.find('div.data-column[column=nickname]').append(this.createBestPlacementBadge(opponent));

        // remove reload button
        row.find('div.data-column[column=reload] button').remove();

        // remove reload column (optional)
        const reload_buttons = $('div.league_table div.body-row div.data-column[column=reload] button').length
        if (reload_buttons == 0) {
            $('div.league_table div.data-row div.data-column[column=reload]').remove();
        }
    }

    makeCompatibleWithLeaguePlusPlus() {
        HHPlusPlus.Helpers.doWhenSelectorAvailable('div#leagues div.league_buttons_block a.changeTeam', () => {
            // 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')}

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

        HHPlusPlus.Helpers.defer(() => {
            setTimeout(() => {
                this.checkMythicEquipment();
            }, 150);
        });

        this.hasRun = true;
    }

    checkMythicEquipment() {
        const me = EquipmentCollector.getBestMythic();
        const equipment_themes = me.map(x => x.resonance_bonuses.theme.identifier);

        // use 'div.player-team' for the opponent's 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);

        const has_matching_me = themes.some(t => equipment_themes.includes(t));
        if (has_matching_me) {
            window.popup_message("You have a perfect mythic equipment for this team in your inventory.")
        }
    }
}

class HaremFiltersModule extends MyModule {
    constructor () {
        const baseKey = 'haremFilters'
        const configSchema = {
            baseKey,
            default: true,
            label: `Show additional harem filters`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('harem') && !HHPlusPlus.Helpers.isCurrentPage('hero')}

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

        HHPlusPlus.Helpers.defer(() => {
            // figure out the event ids for LD
            const ld_event_ids = $("select[name=event] option")
                                    .filter((idx, opt) => opt.text.includes("Legendary Days"))
                                    .map((idx, opt) => opt.value)
                                    .get()
                                    .map(id => parseInt(id));

            // mark the girls as LD girls
            Object.values(window.girlsDataList)
                  .filter(e => e.source_selectors.event)
                  .filter(e => e.source_selectors.event.filter(id => ld_event_ids.includes(id)).length)
                  .forEach(g => g.source_selectors.legendary_days = [0]); // TODO fill id, optional

            // add dropdown option
            $("select[name=lists]").append(new Option("Legendary Days", "legendary_days"));
        });

        this.hasRun = true;
    }
}

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();
    TeamsCollector.collect();

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

    // 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)
  • Hearts 1
Link to comment
Share on other sites

47 minutes ago, DvDivXXX said:

Also, could anyone explain to me what these little extra numbers on some of my opponents mean?

It should look a little different with the updated script, but the numbers reveal the best league results of your opponents (from their player profile):

image.png.282514ea9d72dbce9c40914b5d6f4159.png

Edited by Bobick
  • Hearts 1
Link to comment
Share on other sites

  • Moderator
6 minutes ago, Bobick said:

It should look a little different with the updated script, but the numbers reveal the best league results of your opponents (from their player profile):

That's awesome (and I never would have guessed). Thanks for this and also for taking the time to prepare a copy-paste for me too (just because you got ninja'd by Horsting, doesn't change the fact you made the effort; I appreciate it). ❤️ 

  • Thanks 1
Link to comment
Share on other sites

44 minutes ago, DvDivXXX said:

Now, what kind of light bulbs do you drop, eh? ^^

The blue ones to skill the league buttons, it seems 😄.

Now I just recognised that the last League++ update (I guess) made them even prettier with some space between all buttons, probably caused by "justify-content: space-between;". But @-MM- there are now unintended line breaks in your x15 and "change team" buttons, even causing a text overflow. This is also the case with 430i's script disabled (hence without the two additional buttons at the right side):

image.png.06d2e72b5cba4099700feab353986241.png

Removing the "max-width: 10.5rem;" from the "league_buttons_block" class or increasing it to 15.0rem helps.

Edited by Horsting
  • Haha 1
Link to comment
Share on other sites

34 minutes ago, Horsting said:

Now I just recognised that the last League++ update (I guess) made them even prettier with some space between all buttons, probably caused by "justify-content: space-between;". But @-MM- there are now unintended line breaks in your x15 and "change team" buttons, even causing a text overflow.

This is not from League++ but HH++ v1.37.3. I guess zoo updated the layout for tomorrow's update. Don't worry, it will look good again tomorrow 😊

  • Like 2
Link to comment
Share on other sites

Finally had a bit of time to time to fix the latest issues. Here is the new version.

A list of the features:

  • Show your real team colors, team power and stats (zoo's version only shows the latest snapshot). Of course your stats might change depending on the opponent and your/their counters, but the values shown should be the closest "opponent-independent" values that are available in the game. There are still few known issues, see below.

  •  [updated] Show girl power and themes. The power columns (girl power aka 'Total power' and the new power stat) are now configurable - you can show either one, none or both.

  • Show the expected points for every opponent right in the leagues table.

  • [updated] Load the opponents' profile data when selecting them in the table - this will display their best D3 results in the league table next to the player's name. Configurable from the settings. The additional column has been removed.

  • A check on the pre-battle page whether you have a suitable not-equipped mythic equipment in your inventory.

  • [updated] Compatibility with the base game and other scripts. The layout should be nicely aligned as well. The player avatar had to be hidden to make space for the new data. Updated the styles for the league data download and clear buttons (thanks to @Horsting)

Known issues:

  • [new] I think there is a slight mismatch between the expected points in the table and the actual ones, I will look into that next.
  • Not an issue, but rather a caveat. The script completely re-creates the league table. This means that any changes added to the game will be lost (for example new columns).
  • When you load the league page for the very first time the numbers (your own stats and all expected points) might be off until you open the team selection screen and (re-)select your team
  • When your boosters expire or you apply a new booster, the numbers (your own stats and all expected points) will be incorrect until you visit the team selection page (no need to select a team, I think)
Spoiler
// ==UserScript==
// @name            Hentai Heroes+++
// @description     Few QoL improvement and additions for competitive PvP players.
// @version         0.17.5
// @match           https://*.hentaiheroes.com/*
// @match           https://nutaku.haremheroes.com/*
// @run-at          document-body
// @grant           none
// @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";
const EQUIPMENT_FAVORITE_KEY = EQUIPMENT_KEY + ".Favorite";
const TEAMS_BASE_KEY = LS_CONFIG_NAME + ".Teams";
const TEAMS_ALL_KEY = TEAMS_BASE_KEY + ".All";
const TEAMS_CURRENT_ID_KEY = TEAMS_BASE_KEY + ".CurrentId";

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

// 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.collectPlayerEquipment();
                EquipmentCollector.collectBestMythicEquipment();
            }, 250);
        });
    }

    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 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('tower-of-fame')) {
            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 me() {
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const player = window.opponents_list[r];
            if (player.player.nickname == Hero.infos.name) {
                return player;
            }
        }

        window.popup_message("Could not find myself in the league table.");
    }

    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 TeamsCollector {
    static collect() {
        if (!HHPlusPlus.Helpers.isCurrentPage('teams')) {
            return;
        }

        HHPlusPlus.Helpers.defer(() => {
            // action=select_team&id_team=3680477&battle_type=leagues
            HHPlusPlus.Helpers.onAjaxResponse(/action=select_team&id_team=\d+&battle_type=leagues/, TeamsCollector.collectSelectedTeamFromAjaxResponse);
            TeamsCollector.collectTeams();
        });
    }

    static collectSelectedTeamFromAjaxResponse(response, opt) {
        // Figure out the team id from the request params
        const params = new URLSearchParams(opt.data);
        const team_id = params.get("id_team");

        // Only store the team id to be able to easily refresh the team information when boosters expire or new ones are applied.
        window.localStorage.setItem(TEAMS_CURRENT_ID_KEY, JSON.stringify(parseInt(team_id)));
    }

    static collectTeams() {
        // Create a team map - team_id: team
        const teams = Object.fromEntries(Object.values(teams_data).filter(t => !t.locked).map(t => [parseInt(t.id_team), t]));
        storage.setItem(TEAMS_ALL_KEY, JSON.stringify(teams));
    }

    static getCurrent() {
        const team_id = JSON.parse(window.localStorage.getItem(TEAMS_CURRENT_ID_KEY));
        const all_teams = TeamsCollector.getAll();

        if (team_id && all_teams) {
            return all_teams[team_id];
        }

        window.popup_message(
            `Could not retrieve current team. Team ID: ${team_id}, all teams: ${Boolean(all_teams)}.
            Please open the team selection page and re-select your team.`
        );
    }

    static getAll() {
        return JSON.parse(window.localStorage.getItem(TEAMS_ALL_KEY)) || {};
    }
}

class LeagueSimulator {
    extract(me, other) {
        // This largely uses the upsteam 'League' class (thanks zoop0kemon), but tweaks it to the new input data and
        // takes the themes into account to caculate defence reduction and counter bonuses.
        const {
            team: playerTeam
        } = me.player;

        var {
            ego: playerEgo,
            damage: playerAtk,
            defense: playerDef,
            chance: playerCrit,
        } = playerTeam.caracs;

        // MB2 - All Mastery, MB8 - Leagues Mastery
        const playerHasMasteryMythicBooster = me.boosters.some(b => ['MB2', 'MB8'].includes(b.item.identifier))

        const playerElements = playerTeam.theme_elements.map(({type}) => type);
        const playerWeaknessElements = playerTeam.theme_elements.map(({weakness}) => weakness);
        const playerSynergies = playerTeam.synergies;
        const playerBonuses = {
            critDamage: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'fire'),
            critChance: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'stone'),
            healOnHit: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'water'),
            reduceDefence: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'sun'),
        }

        const {
            nickname: opponentName,
            team: opponentTeam,
        } = other.player;

        var {
            ego: opponentEgo,
            chance: opponentCrit,
            damage: opponentAtk,
            defense: opponentDef,
        } = opponentTeam.caracs

        // MB2 - All Mastery, MB8 - Leagues Mastery
        // The opponent boosters does not currently contain the mastery boosters, but include it in the calculation
        // just in case Kinkoid messes something up.
        const opponentHasMasteryMythicBooster = other.boosters.some(b => ['MB2', 'MB8'].includes(b.item.identifier))

        const opponentTeamMemberElements = [];
        [0,1,2,3,4,5,6].forEach(key => {
            const teamMember = opponentTeam.girls[key]
            if (teamMember && teamMember.element) {
                opponentTeamMemberElements.push(teamMember.element)
            }
        })
        const opponentElements = opponentTeam.theme_elements.map(({type}) => type)
        const opponentWeaknessElements = opponentTeam.theme_elements.map(({weakness}) => weakness);

        const opponentSynergies = opponentTeam.synergies
        const teamGirlSynergyBonusesMissing = opponentSynergies.every(({team_girls_count}) => !team_girls_count)
        let counts
        if (teamGirlSynergyBonusesMissing) {
            // Open bug, sometimes opponent syergy data is missing team bonuses, so we need to rebuild it from the team
            counts = opponentTeamMemberElements.reduce((a,b)=>{a[b]++;return a}, {
                fire: 0,
                stone: 0,
                sun: 0,
                water: 0,
                nature: 0,
                darkness: 0,
                light: 0,
                psychic: 0
            })
        }

        const opponentBonuses = {
            critDamage: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'fire', teamGirlSynergyBonusesMissing, counts),
            critChance: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'stone', teamGirlSynergyBonusesMissing, counts),
            healOnHit: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'water', teamGirlSynergyBonusesMissing, counts),
            reduceDefence: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'sun', teamGirlSynergyBonusesMissing, counts),
        }

        const dominanceBonuses = HHPlusPlus.SimHelpers.calculateDominationBonuses(playerElements, opponentElements);

        const counterElementsAtkEgo = ['fire', 'nature', 'stone', 'sun', 'water'];
        const counterElementsHarmony = ['darkness', 'light', 'psychic'];

        const playerCountersOpponentAtkEgo = opponentWeaknessElements.filter(e => counterElementsAtkEgo.includes(e) && playerElements.includes(e)).length;
        const opponentCountersPlayerAtkEgo = playerWeaknessElements.filter(e => counterElementsAtkEgo.includes(e) && opponentElements.includes(e)).length;

        const playerCountersOpponentHarmony = opponentWeaknessElements.filter(e => counterElementsHarmony.includes(e) && playerElements.includes(e)).length;
        const opponentCountersPlayerHarmony = playerWeaknessElements.filter(e => counterElementsHarmony.includes(e) && opponentElements.includes(e)).length;

        // Attack & Ego
        if (playerCountersOpponentAtkEgo) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.1 * playerCountersOpponentAtkEgo;
            playerAtk *= coef;
            playerEgo *= coef;
        }
        if (opponentCountersPlayerAtkEgo) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.1 * opponentCountersPlayerAtkEgo;
            opponentAtk *= coef;
            opponentEgo *= coef;
        }

        // Mastery boosters
        if (playerHasMasteryMythicBooster) {
            playerAtk *= 1.15;
        }
        if (opponentHasMasteryMythicBooster) {
            opponentAtk *= 1.15;
        }

        // Defence
        // Our opponent's defence has already been reduced when creating the league table
        // opponentDef *= (1 - playerBonuses.reduceDefence);
        playerDef *= (1 - opponentBonuses.reduceDefence);

        // Harmony
        if (playerCountersOpponentHarmony) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.2 * playerCountersOpponentHarmony;
            playerCrit *= coef;
        }
        if (opponentCountersPlayerHarmony) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.2 * opponentCountersPlayerHarmony;
            opponentCrit *= coef;
        }

        // Update the opponent's data
        if (["Kingwask"].includes(opponentName)) {
            console.log(opponentName)
            console.log(me)
            console.log(other)
            console.log()

            console.log(playerBonuses);
            console.log(opponentElements)
            console.log(opponentAtk);
            console.log(opponentEgo);
            console.log(opponentDef)
            console.log(opponentCrit);
            console.log()

            console.log(opponentBonuses);
            console.log(playerElements)
            console.log(playerAtk)
            console.log(playerEgo)
            console.log(playerDef)
            console.log(playerCrit)
            console.log()
        }

        const player = {
            hp: playerEgo,
            dmg: playerAtk - opponentDef,
            critchance: HHPlusPlus.SimHelpers.calculateCritChanceShare(playerCrit, opponentCrit) + dominanceBonuses.player.chance + playerBonuses.critChance,
            bonuses: {...playerBonuses, dominance: dominanceBonuses.player},
            theme: playerElements,
        }
        const opponent = {
            hp: opponentEgo,
            dmg: opponentAtk - playerDef,
            critchance: HHPlusPlus.SimHelpers.calculateCritChanceShare(opponentCrit, playerCrit) + dominanceBonuses.opponent.chance + opponentBonuses.critChance,
            name: opponentName,
            bonuses: {...opponentBonuses, dominance: dominanceBonuses.opponent},
            theme: opponentElements,
        }

        return {player, opponent}
    }
}

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('tower-of-fame')}

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

        $(document).on('league:rollover', () => {
            const data = JSON.parse(storage.getItem(CURRENT_LEAGUE_SNAPSHOT_KEY)) || [];

            storage.setItem(PREVIOUS_LEAGUE_SNAPSHOT_KEY, JSON.stringify(data));
            storage.removeItem(CURRENT_LEAGUE_SNAPSHOT_KEY);
        })

        HHPlusPlus.Helpers.defer(() => {
            // read and store data
            const playerData = this.readPlayerData();
            const snapshot = this.createSnapshot(playerData);
            this.storeSnapshot(snapshot);

            // 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 = player.id_player;
            const rank = player.place;
            const name = player.player.nickname;
            const country = player.country;
            const level = parseInt(player.player.level);
            const points = parseInt(player.player_league_points);
            const elements = player.player.team.theme;

            data.push({id, name, rank, level, elements, points, country});
        }

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

        return data;
    }

    createSnapshot(playerData) {
        const numPlayers = playerData.length;
        const currentDate = new Date(window.server_now_ts * 1000);
        const leagueEndDate = new Date(window.server_now_ts * 1000 + window.season_end_at * 1000);

        return {
            date: currentDate,
            league_end: leagueEndDate,
            num_players: numPlayers,
            player_data: playerData,
        }
    }

    storeSnapshot(snapshot) {
        const data = JSON.parse(storage.getItem(CURRENT_LEAGUE_SNAPSHOT_KEY)) || [];

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

        data.push(snapshot);
        storage.setItem(CURRENT_LEAGUE_SNAPSHOT_KEY, JSON.stringify(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 = JSON.parse(storage.getItem(what)) || [];

            const separator = ","
            const columns = ["date", "player_id", "player_name", "player_rank", "player_points"];
            const values = data.flatMap((e) => e.player_data.map((p) => [e.date, p.id, p.name, p.rank, p.points].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);
        });
    }
}

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: true
                },
                {
                    key: 'new_stat_power',
                    label: 'Show the new power stat in the league table',
                    default: false
                },
                {
                    key: 'load_player_data',
                    label: 'Load player data on league table row click',
                    default: true
                },
            ]
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('tower-of-fame')}

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

        HHPlusPlus.Helpers.defer(() => {
            this.showPlayerLiveData();
            this.extendLeagueDataModel();

            HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_buttons_block', () => {
                this.reinitLeaguesTable(config);
                this.makeCompatibleWithLeaguePlusPlus();
            });

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

        this.hasRun = true;
    }

    showPlayerLiveData() {
        // Show the live data for the player: theme and stats (atk, ego, def, harmony)
        const player = LeaguePlayersCollector.me();
        const team = TeamsCollector.getCurrent();

        if (!team) {
            return;
        }

        // Update the player's data
        player.player.team = team;

        // Update the opponents' data (only defence)
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const opponent = window.opponents_list[r];

            if (opponent == player) {
                continue;
            }

            // Defence
            const reduceDefence = HHPlusPlus.SimHelpers.findBonusFromSynergies(player.player.team.synergies, 'sun');
            opponent.player.team.caracs.defense *= (1 - reduceDefence);
        }
    }

    calculateExpectedPoints(me, other) {
        const sim = new LeagueSimulator();
        const {player, opponent} = sim.extract(me, other);

        const simulator = new HHPlusPlus.Simulator({player, opponent, highPrecisionMode: false, logging: false})
        const result = simulator.run()

        return Object.entries(result.points).map(([pts, coef]) => parseInt(pts) * coef).reduce((acc, a) => acc + a, 0);
    }

    extendLeagueDataModel() {
        const me = LeaguePlayersCollector.me();
        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;

            const expected_points = this.calculateExpectedPoints(me, player);

            player.expected_points = expected_points;
            player.best_placement = best_placement;
            player.placement_count = placement_count;
        }
    }

    // This is mostly copy-pasted from the native 'initLeaguesTable' method with a couple additional changes:
    //  - display best rankings next to the username in the 'name' column
    //  - display girl power in the 'power' column and not whatever crap Kinkoid came up with
    //  - display the theme instead of the synergy popup in the 'team' column
    reinitLeaguesTable(config) {
        // Additional CSS classes
        this.insertRule(`.scriptLeagueInfoIcon.top1 {background-color:var(--var-color-mythic)}`);
        this.insertRule('.scriptLeagueInfoIcon.top1::after {content:"1"}');
        this.insertRule('.league_table .data-list .body-row .data-column[column="team"] span {width: 20px; height: 20px; background-size: 20px}');
        // this.insertRule('.league_table .data-list .body-row .data-column[column="expected_points"] {filter: blur(5px); -webkit-filter: blur(5px);}');

        const row_styles = {total_power: '2rem', expected_points: '1.2rem'}
        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}}`);
        }

        // Recreate the new settings struct with all additional goodies
        const settings = {
            sorting: true,
            sorting_callback: this.addSortCallback,
            column_display_names: {
                place: GT.design.Rank,
                nickname: GT.design.Name,
                team: GT.design.team,
                level: GT.design.Lvl,
                power: GT.design.caracs_sum,
                total_power: GT.design.total_power,
                player_league_points: GT.design.leagues_points,
                match_history_sorting: GT.design.Challenges,
                boosters: GT.design.leagues_stats_and_boosters,
                expected_points: 'E[X]',
                can_fight: GT.design.challenge,
            },
            visible_columns: [
                'place',
                'nickname',
                'level',
                'player_league_points',
                'power',
                'total_power',
                'expected_points',
                'match_history_sorting',
                'team',
                'boosters',
                'can_fight',
            ],
            head_column_html: '<span>[column_value]<span class="upDownArrows_mix_icn"></span></span>',
            body_column_html: {
                nickname: this.createLeagueColumnNickname,
                level: (row) => row.player.level,
                power: (row) => number_reduce(row.player.team.power_display),
                total_power: (row) => row.player.team.total_power.toFixed(),
                team: this.createLeagueColumnTeam,
                player_league_points: (row) => number_reduce(row.player_league_points),
                match_history_sorting: this.createChallengesColumn,
                expected_points: (row) => row.expected_points.toFixed(2),
                boosters: this.createStatsAndBoostersColumn,
                can_fight: this.createChallengeButtonColumn,
                // 'body_column_html' is passed as 'this' to all html functions,
                // so we need to include a couple of utility functions in here
                canFight: this.canFight,
                createBestPlacementBadge: this.createBestPlacementBadge,
            }
        };

        if (!config.girl_power) {
            settings.visible_columns.splice(settings.visible_columns.indexOf('total_power'), 1);
        }
        if (!config.new_stat_power) {
            settings.visible_columns.splice(settings.visible_columns.indexOf('power'), 1);
        }

        initDataList(opponents_list, '#leagues .league_table', settings);

        $('#leagues .league_table').getNiceScroll().remove();
        $('#leagues .league_table').niceScroll({horizrailenabled: false});
        this.addSortCallback();

        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;
        });
    }

    addSortCallback() {
        // Native callback implementation
        $(`.data-list [id-member="${Hero.infos.id}"]`).parent().parent().addClass('player-row')

        // Re-trigger a refiltering of the leagues table in zoo's script
        $(document).trigger('league:table-sorted');
    }

    // Native method, beatified
    canFight(oponent_data) {
        if (Hero.infos.id == oponent_data.player.id_fighter || !oponent_data.hasOwnProperty('match_history')) {
            return false
        }

        return oponent_data.match_history[oponent_data.player.id_fighter].some((battle) => !battle);
    }

    createBestPlacementBadge(row) {
        if (row.best_placement == 1) {
            return `<span class="best-placement"><span class="scriptLeagueInfoIcon top1"></span>${row.placement_count}</span>`;
        } else if (row.best_placement >= 2 && row.best_placement <= 4) {
            return `<span class="best-placement"><span class="scriptLeagueInfoIcon top4"></span>${row.placement_count}</span>`;
        }

        return '';
    }

    // Native method, bodified to include best placement badge
    createLeagueColumnNickname(row) {
        // best placement indicator next to the nickname
        const placement_badge = this.createBestPlacementBadge(row);

        return `<div class="square-avatar-wrapper" ><img src="${row.player.ico}"/></div><span class="country country-${row.country.toLowerCase()}" tooltip hh_title="${row.country_text}"></span><span class="nickname" id-member="${row.player.id_fighter}">${row.player.nickname}</span>${placement_badge}`;
    }

    createLeagueColumnTeam(row) {
        // show theme icons instead of syngergy tooltip
        const themes = (row.player.team.theme || "balanced").split(',');
        const theme_icons = themes.map(t => `<span class="${t}_element_icn ${t}_theme_icn"></span>`);
        return theme_icons.join('');
    }

    // Native method, beatified
    createChallengesColumn(row_data) {
        if (Hero.infos.id == row_data.player.id_fighter || !row_data.hasOwnProperty('match_history')) {
            return '-';
        }

        return row_data.match_history[row_data.player.id_fighter]
                .map(challenge => {
                    const challenge_class = challenge ? challenge.attacker_won : '';
                    const challenge_text = challenge ? challenge.match_points : '';
                    return '<div class="result ' + challenge_class + '">' + challenge_text + '</div>';
                })
                .join('');
    }

    // Native method, beatified
    createStatsAndBoostersColumn(row_data) {
        const is_player = Hero.infos.id == row_data.player.id_fighter;

        const stats_html = buildPlayerStats(
            row_data.player.team.caracs.damage,
            row_data.player.team.caracs.defense,
            row_data.player.team.caracs.ego,
            row_data.player.team.caracs.chance,
            is_player,
            true,
        );

        const boosters_html = row_data.boosters.map((booster) => newReward.slot(booster, 'xs')).join('');

        return stats_html + '<div class="boosters">' + boosters_html + '</div>'
    }

    // Native method, beatified
    createChallengeButtonColumn(row_data) {
        return this.canFight(row_data) ? '<a href="/leagues-pre-battle.html?id_opponent=' + row_data.player.id_fighter + '" class="go_pre_battle blue_button_L">' + GT.design.daily_goals_go + '</a>' : '';
    }

    onPlayerProfile(data) {
        const id = data.id;

        const idx = window.opponents_list.findIndex(x => parseInt(x.player.id_fighter) == id);
        const opponent = window.opponents_list[idx];

        const row = $($('div.league_table div.body-row').get(idx));

        // best placement indicator next to the nickname
        row.find('div.data-column[column=nickname]').append(this.createBestPlacementBadge(opponent));
    }

    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')}

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

        HHPlusPlus.Helpers.defer(() => {
            setTimeout(() => {
                this.checkMythicEquipment();
            }, 150);
        });

        this.hasRun = true;
    }

    checkMythicEquipment() {
        const me = EquipmentCollector.getBestMythic();
        const equipment_themes = me.map(x => x.resonance_bonuses.theme.identifier);

        // use 'div.player-team' for the opponent's 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);

        const has_matching_me = themes.some(t => equipment_themes.includes(t));
        if (has_matching_me) {
            window.popup_message("You have a perfect mythic equipment for this team in your inventory.")
        }

        // TODO: button to equip best mythic equipment
    }
}

class HaremFiltersModule extends MyModule {
    constructor () {
        const baseKey = 'haremFilters'
        const configSchema = {
            baseKey,
            default: true,
            label: `Show additional harem filters`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('harem') && !HHPlusPlus.Helpers.isCurrentPage('hero')}

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

        HHPlusPlus.Helpers.defer(() => {
            // figure out the event ids for LD
            const ld_event_ids = $("select[name=event] option")
                                    .filter((idx, opt) => opt.text.includes("Legendary Days"))
                                    .map((idx, opt) => opt.value)
                                    .get()
                                    .map(id => parseInt(id));

            // mark the girls as LD girls
            Object.values(window.girlsDataList)
                  .filter(e => e.source_selectors.event)
                  .filter(e => e.source_selectors.event.filter(id => ld_event_ids.includes(id)).length)
                  .forEach(g => g.source_selectors.legendary_days = [0]); // TODO fill id, optional

            // add dropdown option
            $("select[name=lists]").append(new Option("Legendary Days", "legendary_days"));
        });

        this.hasRun = true;
    }
}

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();
    TeamsCollector.collect();

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

    // 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)

 

 

  • Thanks 5
Link to comment
Share on other sites

1 hour ago, 430i said:

Here is the new version.

Thanks a lot!

After installing the latest version I have two minor issues:

  • There is not enough space anymore for the expected average / promotion information. Would it be possible to re-size the buttons or even remove the x15 button?

image.png.76cfccb2c3a4b97e8782ca170783c51e.png

  • After using the "Change team" button closing the team selection page leads into nowhere. Workaround is to open the menu and select leagues.

image.png.a125f789e2fe698befe57780dd7bac70.png

Edit: Of course I am not sure, if this is caused by your script, compatibility issues between the scripts or even the latest patch.

Edit 2: After reading the patch notes, it is most likely the latest patch.

image.thumb.png.cda9088308e6f45f257f4a8b4221e5c0.png

Edited by Bobick
  • Like 1
Link to comment
Share on other sites

2 hours ago, Bobick said:

There is not enough space anymore for the expected average / promotion information.

This is caused by the two additional buttons of 430i's script, and made worse by me by given them the blue button appearance which also makes them bigger.  But there is actually enough room: The x15 button (in my case) has quite a large margin at its right side, respectively it is too wide. I looped through the scripts and now I am certain that it is League++ this time @-MM-: Your script enforces 20.5rem width on the "league_buttons_block" class, which was good when it did hold both, the x15 and "change team" buttons. But now each of the two buttons have their own block, which even have a max-width of 10.5rem (overruling the 20.5rem). Unsetting the enforced 20.5rem width solves the issue. The additional buttons from 430i's script will make things appear a little too narrow, but without unintended line breaks, so we can take care of polishing this later.

2 hours ago, Bobick said:

or even remove the x15 button?

Independent of the spacing issue, I'm voting for this, as least as an option. For gold card (and above) users and those which have koban use confirmation disabled, it is dangerous to accidentally click it and ruin the league score, similar to "claim all" buttons. But it should be possible to have it visible without causing line breaks in league stats.

  • Thanks 2
Link to comment
Share on other sites

I have already planned to clean up the outdated code (from the old league layout before today's update). Currently the code supports both versions and is therefore bloated. As soon as I have time (today or tomorrow) I will do so.

@Horsting In the next update, the 20.5rem will no longer be set 😉

 

 EDIT

44 minutes ago, Horsting said:

For gold card (and above) users and those which have koban use confirmation disabled, it is dangerous to accidentally click it and ruin the league score

The x15 button has an additional confirmation since v0.2 😊

Edited by -MM-
  • Thanks 3
Link to comment
Share on other sites

New version, which fixes the issue with the league rewards screen not being shown (instead the next league's table was visible).

Spoiler
// ==UserScript==
// @name            Hentai Heroes+++
// @description     Few QoL improvement and additions for competitive PvP players.
// @version         0.17.6
// @match           https://*.hentaiheroes.com/*
// @match           https://nutaku.haremheroes.com/*
// @run-at          document-body
// @grant           none
// @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";
const EQUIPMENT_FAVORITE_KEY = EQUIPMENT_KEY + ".Favorite";
const TEAMS_BASE_KEY = LS_CONFIG_NAME + ".Teams";
const TEAMS_ALL_KEY = TEAMS_BASE_KEY + ".All";
const TEAMS_CURRENT_ID_KEY = TEAMS_BASE_KEY + ".CurrentId";

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

// 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.collectPlayerEquipment();
                EquipmentCollector.collectBestMythicEquipment();
            }, 250);
        });
    }

    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),
            };
        });

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

    static collectBestMythicEquipment() {
        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('tower-of-fame')) {
            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 me() {
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const player = window.opponents_list[r];
            if (player.player.nickname == Hero.infos.name) {
                return player;
            }
        }

        window.popup_message("Could not find myself in the league table.");
    }

    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 TeamsCollector {
    static collect() {
        if (!HHPlusPlus.Helpers.isCurrentPage('teams')) {
            return;
        }

        HHPlusPlus.Helpers.defer(() => {
            // action=select_team&id_team=3680477&battle_type=leagues
            HHPlusPlus.Helpers.onAjaxResponse(/action=select_team&id_team=\d+&battle_type=leagues/, TeamsCollector.collectSelectedTeamFromAjaxResponse);
            TeamsCollector.collectTeams();
        });
    }

    static collectSelectedTeamFromAjaxResponse(response, opt) {
        // Figure out the team id from the request params
        const params = new URLSearchParams(opt.data);
        const team_id = params.get("id_team");

        // Only store the team id to be able to easily refresh the team information when boosters expire or new ones are applied.
        window.localStorage.setItem(TEAMS_CURRENT_ID_KEY, JSON.stringify(parseInt(team_id)));
    }

    static collectTeams() {
        // Create a team map - team_id: team
        const teams = Object.fromEntries(Object.values(teams_data).filter(t => !t.locked).map(t => [parseInt(t.id_team), t]));
        storage.setItem(TEAMS_ALL_KEY, JSON.stringify(teams));
    }

    static getCurrent() {
        const team_id = JSON.parse(window.localStorage.getItem(TEAMS_CURRENT_ID_KEY));
        const all_teams = TeamsCollector.getAll();

        if (team_id && all_teams) {
            return all_teams[team_id];
        }

        window.popup_message(
            `Could not retrieve current team. Team ID: ${team_id}, all teams: ${Boolean(all_teams)}.
            Please open the team selection page and re-select your team.`
        );
    }

    static getAll() {
        return JSON.parse(window.localStorage.getItem(TEAMS_ALL_KEY)) || {};
    }
}

class LeagueSimulator {
    extract(me, other) {
        // This largely uses the upsteam 'League' class (thanks zoop0kemon), but tweaks it to the new input data and
        // takes the themes into account to caculate defence reduction and counter bonuses.
        const {
            team: playerTeam
        } = me.player;

        var {
            ego: playerEgo,
            damage: playerAtk,
            defense: playerDef,
            chance: playerCrit,
        } = playerTeam.caracs;

        // MB2 - All Mastery, MB8 - Leagues Mastery
        const playerHasMasteryMythicBooster = me.boosters.some(b => ['MB2', 'MB8'].includes(b.item.identifier))

        const playerElements = playerTeam.theme_elements.map(({type}) => type);
        const playerWeaknessElements = playerTeam.theme_elements.map(({weakness}) => weakness);
        const playerSynergies = playerTeam.synergies;
        const playerBonuses = {
            critDamage: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'fire'),
            critChance: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'stone'),
            healOnHit: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'water'),
            reduceDefence: HHPlusPlus.SimHelpers.findBonusFromSynergies(playerSynergies, 'sun'),
        }

        const {
            nickname: opponentName,
            team: opponentTeam,
        } = other.player;

        var {
            ego: opponentEgo,
            chance: opponentCrit,
            damage: opponentAtk,
            defense: opponentDef,
        } = opponentTeam.caracs

        // MB2 - All Mastery, MB8 - Leagues Mastery
        // The opponent boosters does not currently contain the mastery boosters, but include it in the calculation
        // just in case Kinkoid messes something up.
        const opponentHasMasteryMythicBooster = other.boosters.some(b => ['MB2', 'MB8'].includes(b.item.identifier))

        const opponentTeamMemberElements = [];
        [0,1,2,3,4,5,6].forEach(key => {
            const teamMember = opponentTeam.girls[key]
            if (teamMember && teamMember.element) {
                opponentTeamMemberElements.push(teamMember.element)
            }
        })
        const opponentElements = opponentTeam.theme_elements.map(({type}) => type)
        const opponentWeaknessElements = opponentTeam.theme_elements.map(({weakness}) => weakness);

        const opponentSynergies = opponentTeam.synergies
        const teamGirlSynergyBonusesMissing = opponentSynergies.every(({team_girls_count}) => !team_girls_count)
        let counts
        if (teamGirlSynergyBonusesMissing) {
            // Open bug, sometimes opponent syergy data is missing team bonuses, so we need to rebuild it from the team
            counts = opponentTeamMemberElements.reduce((a,b)=>{a[b]++;return a}, {
                fire: 0,
                stone: 0,
                sun: 0,
                water: 0,
                nature: 0,
                darkness: 0,
                light: 0,
                psychic: 0
            })
        }

        const opponentBonuses = {
            critDamage: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'fire', teamGirlSynergyBonusesMissing, counts),
            critChance: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'stone', teamGirlSynergyBonusesMissing, counts),
            healOnHit: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'water', teamGirlSynergyBonusesMissing, counts),
            reduceDefence: HHPlusPlus.SimHelpers.findBonusFromSynergies(opponentSynergies, 'sun', teamGirlSynergyBonusesMissing, counts),
        }

        const dominanceBonuses = HHPlusPlus.SimHelpers.calculateDominationBonuses(playerElements, opponentElements);

        const counterElementsAtkEgo = ['fire', 'nature', 'stone', 'sun', 'water'];
        const counterElementsHarmony = ['darkness', 'light', 'psychic'];

        const playerCountersOpponentAtkEgo = opponentWeaknessElements.filter(e => counterElementsAtkEgo.includes(e) && playerElements.includes(e)).length;
        const opponentCountersPlayerAtkEgo = playerWeaknessElements.filter(e => counterElementsAtkEgo.includes(e) && opponentElements.includes(e)).length;

        const playerCountersOpponentHarmony = opponentWeaknessElements.filter(e => counterElementsHarmony.includes(e) && playerElements.includes(e)).length;
        const opponentCountersPlayerHarmony = playerWeaknessElements.filter(e => counterElementsHarmony.includes(e) && opponentElements.includes(e)).length;

        // Attack & Ego
        if (playerCountersOpponentAtkEgo) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.1 * playerCountersOpponentAtkEgo;
            playerAtk *= coef;
            playerEgo *= coef;
        }
        if (opponentCountersPlayerAtkEgo) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.1 * opponentCountersPlayerAtkEgo;
            opponentAtk *= coef;
            opponentEgo *= coef;
        }

        // Mastery boosters
        if (playerHasMasteryMythicBooster) {
            playerAtk *= 1.15;
        }
        if (opponentHasMasteryMythicBooster) {
            opponentAtk *= 1.15;
        }

        // Defence
        // Our opponent's defence has already been reduced when creating the league table
        // opponentDef *= (1 - playerBonuses.reduceDefence);
        playerDef *= (1 - opponentBonuses.reduceDefence);

        // Harmony
        if (playerCountersOpponentHarmony) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.2 * playerCountersOpponentHarmony;
            playerCrit *= coef;
        }
        if (opponentCountersPlayerHarmony) {
            // TODO is the coeficient additive or multiplicative?
            const coef = 1 + 0.2 * opponentCountersPlayerHarmony;
            opponentCrit *= coef;
        }

        const player = {
            hp: playerEgo,
            dmg: playerAtk - opponentDef,
            critchance: HHPlusPlus.SimHelpers.calculateCritChanceShare(playerCrit, opponentCrit) + dominanceBonuses.player.chance + playerBonuses.critChance,
            bonuses: {...playerBonuses, dominance: dominanceBonuses.player},
            theme: playerElements,
        }
        const opponent = {
            hp: opponentEgo,
            dmg: opponentAtk - playerDef,
            critchance: HHPlusPlus.SimHelpers.calculateCritChanceShare(opponentCrit, playerCrit) + dominanceBonuses.opponent.chance + opponentBonuses.critChance,
            name: opponentName,
            bonuses: {...opponentBonuses, dominance: dominanceBonuses.opponent},
            theme: opponentElements,
        }

        return {player, opponent}
    }
}

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('tower-of-fame')}

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

        $(document).on('league:rollover', () => {
            const data = JSON.parse(storage.getItem(CURRENT_LEAGUE_SNAPSHOT_KEY)) || [];

            storage.setItem(PREVIOUS_LEAGUE_SNAPSHOT_KEY, JSON.stringify(data));
            storage.removeItem(CURRENT_LEAGUE_SNAPSHOT_KEY);
        })

        HHPlusPlus.Helpers.defer(() => {
            // read and store data
            const playerData = this.readPlayerData();
            const snapshot = this.createSnapshot(playerData);
            this.storeSnapshot(snapshot);

            // 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 = player.id_player;
            const rank = player.place;
            const name = player.player.nickname;
            const country = player.country;
            const level = parseInt(player.player.level);
            const points = parseInt(player.player_league_points);
            const elements = player.player.team.theme;

            data.push({id, name, rank, level, elements, points, country});
        }

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

        return data;
    }

    createSnapshot(playerData) {
        const numPlayers = playerData.length;
        const currentDate = new Date(window.server_now_ts * 1000);
        const leagueEndDate = new Date(window.server_now_ts * 1000 + window.season_end_at * 1000);

        return {
            date: currentDate,
            league_end: leagueEndDate,
            num_players: numPlayers,
            player_data: playerData,
        }
    }

    storeSnapshot(snapshot) {
        const data = JSON.parse(storage.getItem(CURRENT_LEAGUE_SNAPSHOT_KEY)) || [];

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

        data.push(snapshot);
        storage.setItem(CURRENT_LEAGUE_SNAPSHOT_KEY, JSON.stringify(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 = JSON.parse(storage.getItem(what)) || [];

            const separator = ","
            const columns = ["date", "player_id", "player_name", "player_rank", "player_points"];
            const values = data.flatMap((e) => e.player_data.map((p) => [e.date, p.id, p.name, p.rank, p.points].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);
        });
    }
}

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: true
                },
                {
                    key: 'new_stat_power',
                    label: 'Show the new power stat in the league table',
                    default: false
                },
                {
                    key: 'load_player_data',
                    label: 'Load player data on league table row click',
                    default: true
                },
            ]
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('tower-of-fame')}

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

        HHPlusPlus.Helpers.defer(() => {
            this.showPlayerLiveData();
            this.extendLeagueDataModel();

            HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_table', () => {
                this.reinitLeaguesTable(config);
                this.makeCompatibleWithLeaguePlusPlus();
            });

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

        this.hasRun = true;
    }

    showPlayerLiveData() {
        // Show the live data for the player: theme and stats (atk, ego, def, harmony)
        const player = LeaguePlayersCollector.me();
        const team = TeamsCollector.getCurrent();

        if (!team) {
            return;
        }

        // Update the player's data
        player.player.team = team;

        // Update the opponents' data (only defence)
        for (var r = 0, n = window.opponents_list.length; r < n; r++) {
            const opponent = window.opponents_list[r];

            if (opponent == player) {
                continue;
            }

            // Defence
            const reduceDefence = HHPlusPlus.SimHelpers.findBonusFromSynergies(player.player.team.synergies, 'sun');
            opponent.player.team.caracs.defense *= (1 - reduceDefence);
        }
    }

    calculateExpectedPoints(me, other) {
        const sim = new LeagueSimulator();
        const {player, opponent} = sim.extract(me, other);

        const simulator = new HHPlusPlus.Simulator({player, opponent, highPrecisionMode: false, logging: false})
        const result = simulator.run()

        return Object.entries(result.points).map(([pts, coef]) => parseInt(pts) * coef).reduce((acc, a) => acc + a, 0);
    }

    extendLeagueDataModel() {
        const me = LeaguePlayersCollector.me();
        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;

            const expected_points = this.calculateExpectedPoints(me, player);

            player.expected_points = expected_points;
            player.best_placement = best_placement;
            player.placement_count = placement_count;
        }
    }

    // This is mostly copy-pasted from the native 'initLeaguesTable' method with a couple additional changes:
    //  - display best rankings next to the username in the 'name' column
    //  - display girl power in the 'power' column and not whatever crap Kinkoid came up with
    //  - display the theme instead of the synergy popup in the 'team' column
    reinitLeaguesTable(config) {
        // Additional CSS classes
        this.insertRule(`.scriptLeagueInfoIcon.top1 {background-color:var(--var-color-mythic)}`);
        this.insertRule('.scriptLeagueInfoIcon.top1::after {content:"1"}');
        this.insertRule('.league_table .data-list .body-row .data-column[column="team"] span {width: 20px; height: 20px; background-size: 20px}');
        // this.insertRule('.league_table .data-list .body-row .data-column[column="expected_points"] {filter: blur(5px); -webkit-filter: blur(5px);}');

        const row_styles = {total_power: '2rem', expected_points: '1.2rem'}
        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}}`);
        }

        // Recreate the new settings struct with all additional goodies
        const settings = {
            sorting: true,
            sorting_callback: this.addSortCallback,
            column_display_names: {
                place: GT.design.Rank,
                nickname: GT.design.Name,
                team: GT.design.team,
                level: GT.design.Lvl,
                power: GT.design.caracs_sum,
                total_power: GT.design.total_power,
                player_league_points: GT.design.leagues_points,
                match_history_sorting: GT.design.Challenges,
                boosters: GT.design.leagues_stats_and_boosters,
                expected_points: 'E[X]',
                can_fight: GT.design.challenge,
            },
            visible_columns: [
                'place',
                'nickname',
                'level',
                'player_league_points',
                'power',
                'total_power',
                'expected_points',
                'match_history_sorting',
                'team',
                'boosters',
                'can_fight',
            ],
            head_column_html: '<span>[column_value]<span class="upDownArrows_mix_icn"></span></span>',
            body_column_html: {
                nickname: this.createLeagueColumnNickname,
                level: (row) => row.player.level,
                power: (row) => number_reduce(row.player.team.power_display),
                total_power: (row) => row.player.team.total_power.toFixed(),
                team: this.createLeagueColumnTeam,
                player_league_points: (row) => number_reduce(row.player_league_points),
                match_history_sorting: this.createChallengesColumn,
                expected_points: (row) => row.expected_points.toFixed(2),
                boosters: this.createStatsAndBoostersColumn,
                can_fight: this.createChallengeButtonColumn,
                // 'body_column_html' is passed as 'this' to all html functions,
                // so we need to include a couple of utility functions in here
                canFight: this.canFight,
                createBestPlacementBadge: this.createBestPlacementBadge,
            }
        };

        if (!config.girl_power) {
            settings.visible_columns.splice(settings.visible_columns.indexOf('total_power'), 1);
        }
        if (!config.new_stat_power) {
            settings.visible_columns.splice(settings.visible_columns.indexOf('power'), 1);
        }

        initDataList(opponents_list, '#leagues .league_table', settings);

        $('#leagues .league_table').getNiceScroll().remove();
        $('#leagues .league_table').niceScroll({horizrailenabled: false});
        this.addSortCallback();

        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;
        });
    }

    addSortCallback() {
        // Native callback implementation
        $(`.data-list [id-member="${Hero.infos.id}"]`).parent().parent().addClass('player-row')

        // Re-trigger a refiltering of the leagues table in zoo's script
        $(document).trigger('league:table-sorted');
    }

    // Native method, beatified
    canFight(oponent_data) {
        if (Hero.infos.id == oponent_data.player.id_fighter || !oponent_data.hasOwnProperty('match_history')) {
            return false
        }

        return oponent_data.match_history[oponent_data.player.id_fighter].some((battle) => !battle);
    }

    createBestPlacementBadge(row) {
        if (row.best_placement == 1) {
            return `<span class="best-placement"><span class="scriptLeagueInfoIcon top1"></span>${row.placement_count}</span>`;
        } else if (row.best_placement >= 2 && row.best_placement <= 4) {
            return `<span class="best-placement"><span class="scriptLeagueInfoIcon top4"></span>${row.placement_count}</span>`;
        }

        return '';
    }

    // Native method, bodified to include best placement badge
    createLeagueColumnNickname(row) {
        // best placement indicator next to the nickname
        const placement_badge = this.createBestPlacementBadge(row);

        return `<div class="square-avatar-wrapper" ><img src="${row.player.ico}"/></div><span class="country country-${row.country.toLowerCase()}" tooltip hh_title="${row.country_text}"></span><span class="nickname" id-member="${row.player.id_fighter}">${row.player.nickname}</span>${placement_badge}`;
    }

    createLeagueColumnTeam(row) {
        // show theme icons instead of syngergy tooltip
        const themes = (row.player.team.theme || "balanced").split(',');
        const theme_icons = themes.map(t => `<span class="${t}_element_icn ${t}_theme_icn"></span>`);
        return theme_icons.join('');
    }

    // Native method, beatified
    createChallengesColumn(row_data) {
        if (Hero.infos.id == row_data.player.id_fighter || !row_data.hasOwnProperty('match_history')) {
            return '-';
        }

        return row_data.match_history[row_data.player.id_fighter]
                .map(challenge => {
                    const challenge_class = challenge ? challenge.attacker_won : '';
                    const challenge_text = challenge ? challenge.match_points : '';
                    return '<div class="result ' + challenge_class + '">' + challenge_text + '</div>';
                })
                .join('');
    }

    // Native method, beatified
    createStatsAndBoostersColumn(row_data) {
        const is_player = Hero.infos.id == row_data.player.id_fighter;

        const stats_html = buildPlayerStats(
            row_data.player.team.caracs.damage,
            row_data.player.team.caracs.defense,
            row_data.player.team.caracs.ego,
            row_data.player.team.caracs.chance,
            is_player,
            true,
        );

        const boosters_html = row_data.boosters.map((booster) => newReward.slot(booster, 'xs')).join('');

        return stats_html + '<div class="boosters">' + boosters_html + '</div>'
    }

    // Native method, beatified
    createChallengeButtonColumn(row_data) {
        return this.canFight(row_data) ? '<a href="/leagues-pre-battle.html?id_opponent=' + row_data.player.id_fighter + '" class="go_pre_battle blue_button_L">' + GT.design.daily_goals_go + '</a>' : '';
    }

    onPlayerProfile(data) {
        const id = data.id;

        const idx = window.opponents_list.findIndex(x => parseInt(x.player.id_fighter) == id);
        const opponent = window.opponents_list[idx];

        const row = $($('div.league_table div.body-row').get(idx));

        // best placement indicator next to the nickname
        row.find('div.data-column[column=nickname]').append(this.createBestPlacementBadge(opponent));
    }

    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')}

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

        HHPlusPlus.Helpers.defer(() => {
            setTimeout(() => {
                this.checkMythicEquipment();
            }, 150);
        });

        this.hasRun = true;
    }

    checkMythicEquipment() {
        const me = EquipmentCollector.getBestMythic();
        const equipment_themes = me.map(x => x.resonance_bonuses.theme.identifier);

        // use 'div.player-team' for the opponent's 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);

        const has_matching_me = themes.some(t => equipment_themes.includes(t));
        if (has_matching_me) {
            window.popup_message("You have a perfect mythic equipment for this team in your inventory.")
        }
    }
}

class HaremFiltersModule extends MyModule {
    constructor () {
        const baseKey = 'haremFilters'
        const configSchema = {
            baseKey,
            default: true,
            label: `Show additional harem filters`,
        }
        super({name: baseKey, configSchema})
    }

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('harem') && !HHPlusPlus.Helpers.isCurrentPage('hero')}

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

        HHPlusPlus.Helpers.defer(() => {
            // figure out the event ids for LD
            const ld_event_ids = $("select[name=event] option")
                                    .filter((idx, opt) => opt.text.includes("Legendary Days"))
                                    .map((idx, opt) => opt.value)
                                    .get()
                                    .map(id => parseInt(id));

            // mark the girls as LD girls
            Object.values(window.girlsDataList)
                  .filter(e => e.source_selectors.event)
                  .filter(e => e.source_selectors.event.filter(id => ld_event_ids.includes(id)).length)
                  .forEach(g => g.source_selectors.legendary_days = [0]); // TODO fill id, optional

            // add dropdown option
            $("select[name=lists]").append(new Option("Legendary Days", "legendary_days"));
        });

        this.hasRun = true;
    }
}

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();
    TeamsCollector.collect();

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

    // 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)

 

 

  • Thanks 3
Link to comment
Share on other sites

HH++ BDSM has been updated to v1.38.0

mainleaguesim.thumb.PNG.7cac26c45fe3aa92810184dcaef867be.PNG

The league sim is back on the main page! Thanks to the method set up by @-MM-, by loading the player-data from the pre-battle page, I'm able to run an accurate sim. Even went the extra mile by normalizing the player data so the stats can be re-calculated pre-opponent so the sim can be ran for all opponents with only one extra network call. It replaces the "power" calculation with the sim results, sortable by the expected score. If you spot any difference between the sim results on the main page and the pre-battle page tell me!. Speaking of the sim it's now updated to factor in tier 4 girl skills, so it should now be equal to @renalove's sim. Also the player's team theme icons get updated according to the currently selected team like @430i's script (only if you have the sim module turned on). And lastly I added a style tweak to hide the league 15x fight button (off by default).

Notes for other scripters, the sim puts its results in the opponents_list[i].power and full results in opponents_list[i].sim, and it triggers $(document).trigger('league:sim-done') so you know when you can collect that info for your own script's purposes.

For what's next, first I'm going to be holding up on merging anymore of MM's script for now, but eventually the opponent view will be added. One thing I do want to add in soon is that damage table rena's sim has. Other then that that should be all for awhile.

  • Thanks 11
  • Hearts 3
Link to comment
Share on other sites

There are two things, that don't work for me, don't know why.

-  I can't pin myself to the top row (no difference if the pin is lit or not).

-  Sorting has to be done for every fight, it doesn't remember the chosen column.

Is it my Firefox, or what do I not get?
I use BDSM and HH+++ (last one is disabled for now, as the results are shown in BDSM).

 

Link to comment
Share on other sites

  • Moderator

@zoopokemon Thanks a ton for this update! I also noticed you've added a @430i's scripts tab to Style Tweaks, which is neat (plural? what am I missing out on? I only have his league one).

If I understood correctly, Rena's sim script is no longer needed since BDSM has a solid sim on its own again, so I disabled that one.

Right now, using the other 3 scripts at once, I get some columns overlapping, which disabling @-MM-'s league script "fixes", but at the cost of no longer having a preview of the opponent's team and stats on the right, and more importantly the ability to fight opponents without exiting the main league page. I suppose the new LG finally making the bare game's right-hand rectangle space functional messed up with the alignment?

With MM's script:

image.png

Without:

image.png

Thanks again, and thanks to MM and 430i (and Rena) too for making the league playable despite Kinkoid's new UI seemingly making its best to prevent it. ❤️ 

Link to comment
Share on other sites

13 minutes ago, DvDivXXX said:

With MM's script:

image.png

Without:

image.png

I will update Leagues++ as soon as possible. Today is just a very bad timing 😖 I'll try to do the first part, though.

First part: Style Tweaks so that it is no longer messy

Second part: I will use the new simulation data

  • Like 1
  • Thanks 2
  • Hearts 4
Link to comment
Share on other sites

25 minutes ago, DvDivXXX said:

Thanks a ton for this update! I also noticed you've added a @430i's scripts tab to Style Tweaks

What you seeing in the top right corner of the homepage is actually the configuration screen for all HH++ compatible scrips. This of course includes HH++ itself, the style tweaks, as well as my the extensions which are part of my script, which is HH++ compatible (actually not only compatible, but dependent of HH++ itself). As such, one can easily create new extensions, with their own config and those will be nicely integrated into HH++. All if this is possible thanks to the amazing extensions framework from @45026831.

 

My script is not rapidly approaching EOL, which is not such a bad thing, however I have one little qualm with the latest HH++ version - I personally love the team/girl power column and often enough use it with conjuncture with the 4 stats to judge whether the player is boosted or not. As such I would love it, if it is resurrected.

I will update my script in the evening.

  • Thanks 5
Link to comment
Share on other sites

  • Moderator

Thanks a lot for this, @-MM-.

One minor inconvenience I've been experiencing since I've noticed the indicators for top 4 or past wins (after visiting the opponent's profile) and that no one else seems to (judging by others' screenshots) is that I have the purple square to highlight top 4s, but not the red square to highlight wins:

image.png

Anyone else experiencing this? Any idea what might cause it?

Link to comment
Share on other sites

Please sign in to comment

You will be able to leave a comment after signing in



Sign In Now
 Share

×
×
  • Create New...