Jump to content

Community Scripts [Links in OP]


DvDivXXX
 Share

Recommended Posts

  • Moderator
9 minutes ago, Master-17 said:

For some reason, the Hentai Heroes++ League Booster Detector was not included in the set.

There's actually a reason, but thank you for preparing the entry (MM style, with supported games and everything, nice!). If Zoo is fine with us linking to it directly, we can just copy-paste from your post and add it to the list on page 1.

6 hours ago, DvDivXXX said:

What I'm wondering is if the implicit rule Numbers used to have in place for that script still applies these days. I'm referring to not giving the link directly so that it's a tiny "treasure hunt" of sorts, and you have to find it yourself to install it (hint: if you have two brain cells active and you know it's a script by Numbers and Zoo, it's not a big challenge ^^ but I liked the idea of limiting this tool to people with two or more brain cells active and the motivation to make one quick search to gain access to an amazing free tool).

Is that still relevant for @zoopokemon as the current maintainer of the script? I don't want to post it if Zoo prefers to keep the "treasure hunt" vibe around it.

EDIT: Come to think of it, for the average player, visiting the forum, then finding this thread might be as much of a "treasure hunt" than just finding Zoo's GitHub and browsing for it... ^^ So maybe I'm being too cautious with this.

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

On 7/28/2023 at 5:38 PM, Tom208 said:

also added the villains

That is mainly the minor Issue I was referring to. 😁

Tried to fix it myself, but as I don't know how it really works, I wasn't succesfull. Just adding Names isn't sufficiant, there has to be something else.

On 7/28/2023 at 7:44 AM, 430i said:

One more small script here:

 

Found a minor Issue with that script.

When an animated Scene of the Girl is selected, the little squares are still highlited when hovering above, and the face changes in the Thumbnail on the left. But the other poses will not be shown in large.

image.png.d51818447264a238a00f1ca76f4734d7.png

The Slideshow cannot be started also, even when another scene is selected it doesn't work, although the image.png.f0e39b51e88284d17d189e23ac971fbe.png is shown.
I have to pick another scene, then choose another girl first and go back. Now it works.

 

// @description  Allows you to display any stage image of any harem girl, owned ones or not. Works also in event display and Places of Power. Includes zoom-in feature to display full-size girl images gallery (lightbox).
Quote

Works also in event display

But only if the Animation is turned of in game settings.

And another funny thing, you can make the Girls fly away. 😁 🕊️
Each time you hover over a square, the pic gets lifted up a bit.

image.png.a7553f653bf4b28c9e6dafb598414f79.png

If you do that fast enough, she just flies away. 😉

 

😉🕊️😁

Edited by Der DinX
  • Haha 2
Link to comment
Share on other sites

On 7/28/2023 at 6:37 PM, Der DinX said:

Found a minor Issue with that script.

I will take a look, but no promises as I dont use the animations.

 

@zoopokemon There is an issue with the latest script version - the league stats at the bottom show up very rarely. I think the league table is created client-side, so not all UI elements that you expect are there, or rather it largely depends on the timing. I think wrapping it in a setTimeout() with 250ms delay should fix it (at least this worked for me).

 

I personally am missing a lot more functionality in the league table, so I have updated my personal script (but you need zoo's script as well, as mine is just an extension of his). Here are few improvements in the league table:

- hide/show opponents

- highlight expired boosters (although who knows whether they really expired)

- show girl power and themes (instead of the useless synergy popup)

- button to load the user profile data (but not really show it) - this will show their best D3 results in the league table, but only after reload

@zoopokemon If some of those are interesting for you, feel free to copy paste them.

I explicitly havent fixed the sorting, as I am hoping that kinkoid will fix it in the next patch.

 

And here is the script:

Spoiler
// ==UserScript==
// @name            Hentai Heroes+++
// @description     Few QoL improvement and additions for competitive PvP players.
// @version         0.14.0
// @match           https://*.hentaiheroes.com/*
// @match           https://nutaku.haremheroes.com/*
// @match           https://*.gayharem.com/*
// @match           https://*.comixharem.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_COLLECTOR_KEY = LS_CONFIG_NAME + ".Equipment.Current";

// 3rd party localStorage keys
const LS_CONFIG_HHPLUSPLUS_NAME = 'HHPlusPlus'
const FOUGHT_OPPONENTS_HIDDEN = LS_CONFIG_HHPLUSPLUS_NAME + "FoughtOpponentsHidden"

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

        HHPlusPlus.Helpers.defer(() => {
            const eqElements = $("#equiped 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.identifier, // e.g "EF10", used to show the icon
                    type: e.type, // always "armor"
                    subtype: parseInt(e.subtype), // 1, 2, 3, 4, 5 or 6
                    mono_rainbow: parseInt(e.name_add), // 1 for hardcore only, 2 for charm only, 3 for know-how only, 16 for rainbow, etc..
                    carac1: e.carac1,
                    carac2: e.carac2,
                    carac3: e.carac3,
                    harmony: e.chance,
                    endurance: e.endurance,
                };
            });

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

    static get() {
        return JSON.parse(window.localStorage.getItem(EQUIPMENT_COLLECTOR_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) {
        const html = $("<div/>").html(response.html);

        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 = html.find('.hero_items .slot.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);
    }

    static collectPlayerData() {
        const me = LeaguePlayersCollector.me();

        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 expected_points = LeaguePlayersCollector.calculateExpectedPoints(me, player);

            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,
                expected_points,
            }

            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 == memberInfo.name) {
                return player;
            }
        }

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

    static calculateExpectedPoints(me, other) {
        return undefined;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

    shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('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
            setTimeout(() => {
                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);
            }, 250);
        });

        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}" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" width="16px" height="16px" fill="#FFFFFF" style="cursor: pointer;"><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.extendLeagueDataModel();

            setTimeout(() => {
                this.showHideUnhideButton();
                this.extendLeagueTable();
            }, 250);
        });

        this.hasRun = true;
    }

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

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

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

            window.opponents_list[r].power = power;
            window.opponents_list[r].expected_points = parseFloat(expected_points);
            window.opponents_list[r].best_placement = best_placement;
            window.opponents_list[r].placement_count = placement_count;
        }
    }

    extendLeagueTable() {
        // 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 .data-row .data-column[column="team"] span {background-size: 2em}');
        this.insertRule('.league_table .data-list .data-row .data-column[column="reload"] button {padding: 2px 5px}');

        // Highlight expired boosters
        $('div.league_table div.data-column[column=boosters] div[type=booster]')
        .each(function(index) {
            const d = $(this).data('d');
            if (d.expiration == 0) {
                $(this).css({'border': '1px solid red'});
            }
        })

        // Create "power", "e[x]" ...
        $('div.league_table div.head-row > div.head-column[column=team]')
        .after(
            '<div class="data-column head-column" column="power">Power</div>' +
            '<div class="data-column head-column" column="expected_points">E[X]</div>'
        );

        // ... and "reload" headers.
        $('div.league_table div.head-row > div.head-column:last-child')
        .after('<div class="data-column head-column" column="reload"></div>');

        // Populate additional league table columns:
        //  - add "power" and "e[x]" data
        //  - add best placement indicators next to the opponents name
        //  - replace synergy tooltip with theme icons
        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const opponent = window.opponents_list[index];

                // power and e[x] columns
                const power = opponent.power;
                const e_x = opponent.expected_points;
                $(this).find('div.data-column[column=team]').after(
                    `<div class="data-column" column="power">${(power).toFixed()}</div>
                    <div class="data-column" column="expected_points">${(e_x).toFixed(2)}</div>`
                );

                // best placement indicator next to the nickname
                if (opponent.best_placement == 1) {
                    $(this).find('div.data-column[column=nickname]')
                    .append(`<span><span class="scriptLeagueInfoIcon top1"></span>${opponent.placement_count}</span>`)
                } else if (opponent.best_placement >= 2 && opponent.best_placement <= 4) {
                    $(this).find('div.data-column[column=nickname]')
                    .append(`<span><span class="scriptLeagueInfoIcon top4"></span>${opponent.placement_count}</span>`)
                }

                // show theme icons instead of syngergy tooltip
                const themes = opponent.player.team.theme.split(',').map(t => t || "balanced");
                const theme_icons = themes.map(t => `<span class="${t}_element_icn ${t}_theme_icn"></span>`);
                $(this).find('div.data-column[column=team]').html(theme_icons.join(''));

                // show best placement and mythic equipment reload button
                if (opponent.best_placement == undefined) {
                    const load_button = $('<button class="blue_button_L"></button>');
                    load_button.on('click', () => {
                        window.$.post({
                            url: '/ajax.php',
                            data: {
                                action: 'fetch_hero',
                                id: 'profile',
                                preview: false,
                                player_id: parseInt(opponent.player.id_fighter),
                            },
                            success: (data) => {}
                        })
                    })

                    const div = $('<div class="data-column" column="reload"/>');
                    div.html(load_button)
                    $(this).find('div.data-column:last-child').after(div);
                }
            });

        // re-run the league table sorting with the additional "power" sort option
        // var sort_options = { level: 'number', nb_challenges_played: 'number', power: 'number', expected_points: 'number', place: 'number'};
        // var sort_class = new TableSorting(opponents_list, 'id_player', 'place', sort_options, 'leagues', 'class');
        // sort_class.init();
    }

    showHideUnhideButton() {
        const hide = JSON.parse(storage.getItem(FOUGHT_OPPONENTS_HIDDEN)) || false;
        const caption = hide ? "Show" : "Hide";

        this.hideUnhide(hide);

        const btn = $(`<button id="beaten_opponents2" class="blue_button_L">${caption}</button>`);
        btn.on('click', () => {
            const next = !JSON.parse(storage.getItem(FOUGHT_OPPONENTS_HIDDEN));

            this.hideUnhide(next);
            storage.setItem(FOUGHT_OPPONENTS_HIDDEN, next);
            btn.text(next ? "Show" : "Hide");
        });

        $('div.league_buttons').append(btn);
    }

    hideUnhide(hide) {
        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const results = $(this).find('div.data-column[column=match_history]').find('div[class!="result "]').length;
                const fought_all = results == 3;
                if (fought_all && hide) {
                    $(this).hide();
                } else if (fought_all && !hide) {
                    $(this).show();
                }
            });

        $('#leagues .league_content .league_table').getNiceScroll().resize();
    }
}

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
    CurrentEquipmentCollector.collect()
    LeaguePlayersCollector.collect()

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

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

 

 

Edited by Ravi-Sama
Ravi edit: added spoiler tag, so don't have to scroll.
  • Thanks 6
  • Hearts 1
Link to comment
Share on other sites

2 hours ago, 430i said:

And here is the script:

Thanks a ton! All the additions are very helpful.

Do you expect any conflicts with zoo's scripts in future, or can the scripts stay installed in parralel?

I see only NaN values for E(X), even after entering the battle screens. Is there something I can do to avoid this? Furthermore, is there a smart way to reload the table after triggering the opponent information button? Reloading the page does close the table, so I always had to enter and leave the (or another) battle screen.

Good to know there are even more capable coders in the community, increasing my hopes that the scripts will remain functional.

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

34 minutes ago, Bobick said:

Do you expect any conflicts with zoo's scripts in future, or can the scripts stay installed in parralel?

I expect that there will be conflicts at some point, if/when he introduces similar features, but I will fix them and upload a new version. The more features are part of zoo's version the better is for the community (as his version is widely used), however I am not sure about his motivation levels and whether he has even anything planned for HH++ - his last message was very downbeat. Hence the reason for my version - the current league rework has actually a lot of potential, but kinkoid is in now way taking advantage of that.

41 minutes ago, Bobick said:

I see only NaN values for E(X)

Yeah that's expected, as it is still work in progress. I have a basic version running, but there are still a ton of issues with the calculation, so I havent shared it yet.

44 minutes ago, Bobick said:

Furthermore, is there a smart way to reload the table after triggering the opponent information button?

Yes there is, I did the bare minimum to get some usable league data and will work on improving the usability next.

  • Thanks 1
Link to comment
Share on other sites

7 hours ago, 430i said:

@zoopokemon There is an issue with the latest script version - the league stats at the bottom show up very rarely. I think the league table is created client-side, so not all UI elements that you expect are there, or rather it largely depends on the timing. I think wrapping it in a setTimeout() with 250ms delay should fix it (at least this worked for me).

 

I personally am missing a lot more functionality in the league table, so I have updated my personal script (but you need zoo's script as well, as mine is just an extension of his). Here are few improvements in the league table:

- hide/show opponents

- highlight expired boosters (although who knows whether they really expired)

- show girl power and themes (instead of the useless synergy popup)

- button to load the user profile data (but not really show it) - this will show their best D3 results in the league table, but only after reload

@zoopokemon If some of those are interesting for you, feel free to copy paste them.

I explicitly haven't fixed the sorting, as I am hoping that kinkoid will fix it in the next patch.

Yeah, I forgot to add a Helpers.doWhenSelectorAvailable for that, will be fixed in the next update.

Already working on adding a quick nav to the pre-battle page, and replacing the team ! with the team themes. Planning on replacing the hide/show opponents with a better filtering system that would include fightable opponent, team theme, and booster status. I might take the highlight expired boosters, but since the that's all bugged idk. I don't think I can add in the load the user profile data. Also note that for your script it breaks if you sort the table.

Also the league sim simply can't be ran in the league page any more because your very own data there doesn't get updated when you switch teams or otherwise change your stats, not to mention that the stats aren't dynamically updated based on the opponent. If it could I would replace the "power" with the expected score.

To better keep track of my plans for HH++ BDSM I suggest visiting the HH++ discord linked in the script's settings, I don't engage in the forum too often.

  • Like 1
  • Thanks 3
  • Hearts 1
  • uwu 1
Link to comment
Share on other sites

10 hours ago, zoopokemon said:

Also the league sim simply can't be ran in the league page any more because your very own data there doesn't get updated when you switch teams or otherwise change your stats, not to mention that the stats aren't dynamically updated based on the opponent.

Theoretically you could calculate at least the defence per opponent based on each playful penalty. But yeah, at least the own stats on the table are not accurate, e.g. they do not change on the table (immediately) when you change your team. Alternative would be to save the result from the battle page to show on the table, as a memory. It may not be accurate anymore, as the opponent may have changed (especially booster status), but it may give an orientation. ... but probably causes more confusion than help 🤔.

Link to comment
Share on other sites

  • Moderator
18 hours ago, 430i said:

I personally am missing a lot more functionality in the league table, so I have updated my personal script (but you need zoo's script as well, as mine is just an extension of his). Here are few improvements in the league table:

- hide/show opponents

- highlight expired boosters (although who knows whether they really expired)

- show girl power and themes (instead of the useless synergy popup)

- button to load the user profile data (but not really show it) - this will show their best D3 results in the league table, but only after reload

I really like these upgrades, thanks!  Highlighted expired boosters red is especially helpful. 

Also, I used to visit the player profile pages to check best rank, and sometimes mythic gear.  Hopefully, they'll return too.  Can always copy the game IDs, and manually create profile links, but that's too tedious.

For example: Ravi-Sama's ID 659374
Profile Link: https://www.hentaiheroes.com/hero/659374/profile.html
Can replace the ID #s w/ another player's, to view their profile.

Was wondering if it's possible to sort players by booster expiration times, but maybe that could be more easily tested after sorting is fixed next week.  If possible, then there'd be even less scrolling.

4 hours ago, Ravi-Sama said:

Also, have some good news that league sorting should be fixed by next week, and the change team function will be re-added to the league page.  We'll eventually be able to choose either the girl or stats view, as the default.  I prefer stats.  It's just more practical.  Have to keep switching back in PSH atm.

2023-08-05_4-18-57.png 2023-08-05_4-19-04.png

  • Like 1
Link to comment
Share on other sites

Below is the updated version of my script (a mod can move the relevant posts to a new topic if you deem necessary).

A list of the features:

  • Hide/show fought opponents

  • Highlight expired boosters (although who knows whether they really expired)

  • Show girl power and themes (instead of the useless synergy popup)

  • [updated] Button to load the user profile data (but not really show it) - this will display their best D3 results in the league table next to the player's name. No need to reload the page anymore and the column is hidden when all data is loaded.

  • [new] Show your real team colors, team power and stats (the previous version only showed the snapshotted data, zoo's version as well 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.

  • [new] Show the expected points for every opponent right in the leagues table. Right now the information is blurred, but it is trivial to unblur it (left as an exercise for the reader).

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

Known issues:

  • 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
  • similarly 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.16.8
// @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 FOUGHT_OPPONENTS_HIDDEN = LS_CONFIG_HHPLUSPLUS_NAME + "FoughtOpponentsHidden"

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

        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

        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.some(e => counterElementsAtkEgo.includes(e) && playerElements.includes(e));
        const opponentCountersPlayerAtkEgo = playerWeaknessElements.some(e => counterElementsAtkEgo.includes(e) && opponentElements.includes(e));

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

        // Attack & Ego
        if (playerCountersOpponentAtkEgo) {
            playerAtk *= 1.1;
            playerEgo *= 1.1;
        }
        if (opponentCountersPlayerAtkEgo) {
            opponentAtk *= 1.1;
            opponentEgo *= 1.1;
        }

        // 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) {
            playerCrit *= 1.2;
        }
        if (opponentCountersPlayerHarmony) {
            opponentCrit *= 1.2;
        }

        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}" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" width="16px" height="16px" fill="#FFFFFF" style="cursor: pointer;"><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.showHideUnhideButton();
                this.extendLeagueTable();
                this.renderUpdatedStats();
            });

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

    renderUpdatedStats() {
        // Update the UI to show the updated stats from the 'opponents_list' data model:
        //  - the player stats with the stats of the currently selected team and not the one from the last snapshot
        //  - the opponent's stats based on the currently selected player team
        $('div.league_table div.body-row')
        .each(function(row_index) {
            const team = opponents_list[row_index].player.team;

            $(this).find('div.data-column[column=boosters] div.player_stats span.carac_value')
            .each(function(carac_idx) {
                const carac_name = this.id.split("-")[1];
                const carac_value = team[carac_name] || team.caracs[carac_name];

                $(this).text(number_reduce(carac_value));
            });
        });
    }

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

    extendLeagueTable() {
        // 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}');

        // Highlight expired boosters
        $('div.league_table div.data-column[column=boosters] div[type=booster]')
        .each(function(index) {
            const d = $(this).data('d');
            if (d.expiration == 0) {
                $(this).css({'border': '1px solid red'});
            }
        })

        // Create "power", "e[x]" ...
        $('div.league_table div.head-row div.head-column[column=team]')
        .after(
            '<div class="data-column head-column" column="power">Power</div>' +
            '<div class="data-column head-column" column="expected_points">E[X]</div>'
        );

        // ... and "reload" headers.
        $('div.league_table div.head-row div.head-column:last-child')
        .after('<div class="data-column head-column" column="reload">↻</div>');

        // Populate additional league table columns:
        //  - add "power" and "e[x]" data
        //  - add best placement indicators next to the opponents name
        //  - replace synergy tooltip with theme icons
        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const opponent = window.opponents_list[index];

                // power and e[x] columns
                const power = opponent.player.team.total_power;
                const e_x = opponent.expected_points;
                $(this).find('div.data-column[column=team]').after(
                    `<div class="data-column" column="power">${(power).toFixed()}</div>
                    <div class="data-column" column="expected_points">${(e_x).toFixed(2)}</div>`
                );

                // best placement indicator next to the nickname
                if (opponent.best_placement == 1) {
                    $(this).find('div.data-column[column=nickname]')
                    .append(`<span><span class="scriptLeagueInfoIcon top1"></span>${opponent.placement_count}</span>`)
                } else if (opponent.best_placement >= 2 && opponent.best_placement <= 4) {
                    $(this).find('div.data-column[column=nickname]')
                    .append(`<span><span class="scriptLeagueInfoIcon top4"></span>${opponent.placement_count}</span>`)
                }

                // show theme icons instead of syngergy tooltip
                const themes = opponent.player.team.theme.split(',').map(t => t || "balanced");
                const theme_icons = themes.map(t => `<span class="${t}_element_icn ${t}_theme_icn"></span>`);
                $(this).find('div.data-column[column=team]').html(theme_icons.join(''));

                // add empty reload column
                $(this).find('div.data-column:last-child').after('<div class="data-column" column="reload"/>');

                // show best placement and mythic equipment reload button if needed
                if (opponent.best_placement == undefined) {
                    const load_button = $('<button class="blue_button_L">↻</button>');
                    load_button.on('click', () => {
                        window.$.post({
                            url: '/ajax.php',
                            data: {
                                action: 'fetch_hero',
                                id: 'profile',
                                preview: false,
                                player_id: parseInt(opponent.player.id_fighter),
                            },
                            success: (data) => {}
                        })
                    })

                    $(this).find('div.data-column[column=reload]').html(load_button);
                }
            });

        // If all player data has been loaded we can remove the whole reload column
        this.removeReloadColumnIfNeeded()

        // re-run the league table sorting with the additional "power" sort option
        // var sort_options = { level: 'number', nb_challenges_played: 'number', power: 'number', expected_points: 'number', place: 'number'};
        // var sort_class = new TableSorting(opponents_list, 'id_player', 'place', sort_options, 'leagues', 'class');
        // sort_class.init();
    }

    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
        if (opponent.best_placement == 1) {
            row.find('div.data-column[column=nickname]')
            .append(`<span><span class="scriptLeagueInfoIcon top1"></span>${opponent.placement_count}</span>`)
        } else if (opponent.best_placement >= 2 && opponent.best_placement <= 4) {
            row.find('div.data-column[column=nickname]')
            .append(`<span><span class="scriptLeagueInfoIcon top4"></span>${opponent.placement_count}</span>`)
        }

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

        // remove reload column (optional)
        this.removeReloadColumnIfNeeded()
    }

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

    showHideUnhideButton() {
        const hide = JSON.parse(storage.getItem(FOUGHT_OPPONENTS_HIDDEN)) || false;
        const caption = hide ? "Show" : "Hide";

        this.hideUnhide(hide);

        const btn = $(`<button id="beaten_opponents2" class="blue_button_L">${caption}</button>`);
        btn.on('click', () => {
            const next = !JSON.parse(storage.getItem(FOUGHT_OPPONENTS_HIDDEN));

            this.hideUnhide(next);
            storage.setItem(FOUGHT_OPPONENTS_HIDDEN, next);
            btn.text(next ? "Show" : "Hide");
        });

        $('div.league_buttons').append(btn);
    }

    hideUnhide(hide) {
        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const results = $(this).find('div.data-column[column=match_history]').find('div[class!="result "]').length;
                const fought_all = results == 3;
                if (fought_all && hide) {
                    $(this).hide();
                } else if (fought_all && !hide) {
                    $(this).show();
                }
            });

        $('#leagues .league_content .league_table').getNiceScroll().resize();
    }
}

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)

 

Edited by Ravi-Sama
Ravi edit: added spoiler tag, so don't have to scroll.
  • Thanks 6
Link to comment
Share on other sites

 // If you are reading this, please look away, ugly code below

😁

 

52 minutes ago, 430i said:

Right now the information is blurred, but it is trivial to unblur it (left as an exercise for the reader).

Seems only to be trivial for those who can really read and understand this code.
I partly can tell which part of the text is for what part of the game, but that's about it.
Couldn't find the needed hint jet.

-------

Found it 😉 🤪

Edited by Der DinX
Link to comment
Share on other sites

  • Moderator
17 hours ago, 430i said:

[new] Show the expected points for every opponent right in the leagues table. Right now the information is blurred, but it is trivial to unblur it (left as an exercise for the reader).

How I unblurred the E[X] column:

Spoiler

I searched "blur" and then set the two 5px values to 0px, to unblur the E[X]s.  At first I deleted that part, but that removed the whole column.  Setting them to 0 worked.  Not sure if that's the best solution to that "exercise."

 Also added the "//" like @Horsting recommended.

image.png

That section of code.

Spoiler
extendLeagueTable() {
        // 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(0px); -webkit-filter: blur(0px);}');
        this.insertRule('.league_table .data-list .body-row .data-column[column="reload"] button {padding: 2px 5px}');

 

  • Like 1
  • Thanks 1
Link to comment
Share on other sites

Two remarks:

1. The code parts can be hidden with a [/spoiler] tag, like Ravi did above me, so we won't have to scroll through endless code.

2. The booster detector script is no longer relevant, and can be deleted from the OP (after @DvDivXXX contemplated so much whether to publish it or not 😁). It simply isn't working anymore.

 

P.S. I was rather against publishing the booster detector script on the first page, since I was afraid that I would lose some advantage that I have. But I guess: a. most of the players doesn't visit the forum, only a tiny percentage does. b. It isn't relevant anymore 😆

Edited by OmerB
  • Like 4
Link to comment
Share on other sites

16 hours ago, Horsting said:

@430i

Many thanks for the update. Did you test compatibility with MM's script, is it? When I tested your first version, the table was not well aligned anymore, which could be solved by manually commenting out the e[x] column, which was anyway a WIP and not that valuable anymore with MM's side panel preview.

It should be aligned now, although I dont care too much about that, however I havent tested it with MM's script, so there might be few layout issues. I will take a look later and see whether there are issue and how they can be fixed.

  • Like 1
Link to comment
Share on other sites

4 hours ago, 430i said:

It should be aligned now, although I dont care too much about that, however I havent tested it with MM's script, so there might be few layout issues.

There are still layout issues. Major problem is that MM's script has the sidebar always shown, either with the girl or with the selected opponent info. And this clashes with the additional columns your's adds. And MM added expired booster highlights/fade some minutes ago, so the overlap of what both do raises.

I still like the E[X] column your script adds. So no need to select each opponent, and perspectively also nice for sorting. But somehow the values are again wrong. Not sure what happened, at first they were correct after I loaded the teams edit page once, now they are wrong (including my own stats in the table) and I did not find a way to fix it. My stats are now somewhere in the middle between my snapshot and the actual ones shown on battle pages. Not sure if girls' skills are missing or so. Will probably have a deeper look into it tonight.

But for my understanding, so I can better look for and understand things: Your script loads the actual team stats from the team edit page, right? If I see it right, the AME/LME AP bonus is not shown on the team edit page, so it is not taken into account by your script either. That might be already it and should be easy to fix.

EDIT: Jep indeed it is the exact stats fetched from the team edit page. It is however missing the AM/LM bonus. Multiplying the team edit page AP value with 1.15 somehow does not bringt me to the exact AP value, when assuming mathematical correct rounding, but I guess the value shown on the team edit page is consequently rounded up, then it works. Even if it is not 100% correct, the E[X] values will be much closer to the real ones.

A remaining error is the missing playful defence penalty. Also this data should be available to have the E[X] further corrected. But it won't make a big difference in most cases.

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

6 hours ago, Horsting said:

Your script loads the actual team stats from the team edit page, right?

Indeed. So far this has worked quite good for me, as long as you "refresh" the team whenever your boosters change. Besides the few known issues you should always see identical stats in the leagues table compared to the teams table (there is no additional calculation taking place at this point). However in order to calculate the correct E[X] the following additional calculations are taking place (which you have probably already gleaned from the code):

  • Increase your or your opponent's attack and ego in case of counter elements. Here there is a bug, which currently only takes into account whether there is any counter element, and not how many counter elements - I assume a blue/yellow team should get a 20% (or is it 21% if the bonuses are multiplicative) vs a red/blue team. Right now you only get 10%, will fix it soon.
  • Increase your attack by 15% in case of a mastery booster. This is currently missing, as I didn't have one equipped and forgot about it.
  • Reduce your and your opponent's defence based on each other's yellow bonus.
  • Increase your and your opponent's harmony in case of counter elements (the note from the first bullet point should apply here as well).

Applying those calculation gives me the exact same E[X] in the league table as the one shown on the pre-battle page for every opponent I have checked so far. I am not sure why it gives you slightly different values, it might be due to some collisions with MM's script, I dont know. I will check it tomorrow, same as with the layout issues.

I am hoping that most of my and MM's features make it in zoo's script, but this might take a bit, so for now you might need several scripts to make the most out of the new layout. [Insert now there's two of them meme here]. And all of this might change on Wednesday anyway...

  • Thanks 6
Link to comment
Share on other sites

Updated version, which should fix most of the aforementioned issues. Feedback welcome!

 

A list of the features:

  • Hide/show fought opponents

  • Highlight expired boosters (although who knows whether they really expired)

  • Show girl power and themes (instead of the useless synergy popup)

  • Button to load the user profile data (but not really show it) - this will display their best D3 results in the league table next to the player's name. No need to reload the page anymore and the column is hidden when all data is loaded.

  • Show your real team colors, team power and stats (the previous version only showed the snapshotted data, zoo's version as well 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 the expected points for every opponent right in the leagues table. Data is now unblurred by default. Fix expected score calculation when AM/LM boosters are active and there are more than 1 counter elements

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

  • [new] Compatibility with other scripts, most notably League++. Few layout compromises had to be made to make space for all the new data: narrower rank column, hide the player avatar, remove the native 'power' column, squeeze the league end timer

Known issues:

  • 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
  • similarly 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.16.13
// @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 FOUGHT_OPPONENTS_HIDDEN = LS_CONFIG_HHPLUSPLUS_NAME + "FoughtOpponentsHidden"

// 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}" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" width="16px" height="16px" fill="#FFFFFF" style="cursor: pointer;"><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.showHideUnhideButton();
                this.extendLeagueTable();
                this.renderUpdatedStats();
            });

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

    renderUpdatedStats() {
        // Update the UI to show the updated stats from the 'opponents_list' data model:
        //  - the player stats with the stats of the currently selected team and not the one from the last snapshot
        //  - the opponent's stats based on the currently selected player team
        $('div.league_table div.body-row')
        .each(function(row_index) {
            const team = opponents_list[row_index].player.team;

            $(this).find('div.data-column[column=boosters] div.player_stats span.carac_value')
            .each(function(carac_idx) {
                const carac_name = this.id.split("-")[1];
                const carac_value = team[carac_name] || team.caracs[carac_name];

                $(this).text(number_reduce(carac_value));
            });
        });
    }

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

    extendLeagueTable() {
        // 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}');

        // Highlight expired boosters
        $('div.league_table div.data-column[column=boosters] div[type=booster]')
        .each(function(index) {
            const d = $(this).data('d');
            if (d.expiration == 0 && parseInt(d.usages_remaining) == 0) {
                $(this).css({'border': '1px solid red'});
            }
        })

        // Create "power", "e[x]" ...
        $('div.league_table div.head-row div.head-column[column=team]')
        .after(
            '<div class="data-column head-column" column="total_power">Power</div>' +
            '<div class="data-column head-column" column="expected_points">E[X]</div>'
        );

        // ... and "reload" headers.
        $('div.league_table div.head-row div.head-column:last-child')
        .after('<div class="data-column head-column" column="reload"></div>');

        // Populate additional league table columns:
        //  - add "power" and "e[x]" data
        //  - add best placement indicators next to the opponents name
        //  - replace synergy tooltip with theme icons
        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const opponent = window.opponents_list[index];

                // power and e[x] columns
                const power = opponent.player.team.total_power;
                const e_x = opponent.expected_points;
                $(this).find('div.data-column[column=team]').after(
                    `<div class="data-column" column="total_power">${(power).toFixed()}</div>
                    <div class="data-column" column="expected_points">${(e_x).toFixed(2)}</div>`
                );

                // best placement indicator next to the nickname
                if (opponent.best_placement == 1) {
                    $(this).find('div.data-column[column=nickname]')
                    .append(`<span class="best-placement"><span class="scriptLeagueInfoIcon top1"></span>${opponent.placement_count}</span>`)
                } else if (opponent.best_placement >= 2 && opponent.best_placement <= 4) {
                    $(this).find('div.data-column[column=nickname]')
                    .append(`<span class="best-placement"><span class="scriptLeagueInfoIcon top4"></span>${opponent.placement_count}</span>`)
                }

                // show theme icons instead of syngergy tooltip
                const themes = opponent.player.team.theme.split(',').map(t => t || "balanced");
                const theme_icons = themes.map(t => `<span class="${t}_element_icn ${t}_theme_icn"></span>`);
                $(this).find('div.data-column[column=team]').html(theme_icons.join(''));

                // add empty reload column
                $(this).find('div.data-column:last-child').after('<div class="data-column" column="reload"/>');

                // show best placement and mythic equipment reload button if needed
                if (opponent.best_placement == undefined) {
                    const load_button = $('<button class="blue_button_L"></button>');
                    load_button.on('click', () => {
                        window.$.post({
                            url: '/ajax.php',
                            data: {
                                action: 'fetch_hero',
                                id: 'profile',
                                preview: false,
                                player_id: parseInt(opponent.player.id_fighter),
                            },
                            success: (data) => {}
                        })
                    })

                    $(this).find('div.data-column[column=reload]').html(load_button);
                }
            });

        // If all player data has been loaded we can remove the whole reload column
        this.removeReloadColumnIfNeeded();

        // Compatibility with other scripts
        this.makeCompatibleWithLeaguePlusPlus();

        // re-run the league table sorting with the additional "power" sort option
        // var sort_options = { level: 'number', nb_challenges_played: 'number', power: 'number', expected_points: 'number', place: 'number'};
        // var sort_class = new TableSorting(opponents_list, 'id_player', 'place', sort_options, 'leagues', 'class');
        // sort_class.init();
    }

    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
        if (opponent.best_placement == 1) {
            row.find('div.data-column[column=nickname]')
            .append(`<span><span class="scriptLeagueInfoIcon top1"></span>${opponent.placement_count}</span>`)
        } else if (opponent.best_placement >= 2 && opponent.best_placement <= 4) {
            row.find('div.data-column[column=nickname]')
            .append(`<span><span class="scriptLeagueInfoIcon top4"></span>${opponent.placement_count}</span>`)
        }

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

        // remove reload column (optional)
        this.removeReloadColumnIfNeeded()
    }

    removeReloadColumnIfNeeded() {
        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() {
        // If the League++ script is active, the layout is messed up, so we need to hide to default 'Power' column to fix it.
        HHPlusPlus.Helpers.doWhenSelectorAvailable('div#leagues div.league_buttons_block a.changeTeam', () => {
            // Make the 'place' colum narrower
            this.insertRule('#leagues.hidden_girl .league_table .data-list .data-row .data-column[column="place"] {min-width: 1rem}');

            // Make the league ends label smaller to find real estate for the show/hide button
            this.insertRule('.league_end_in {min-width: 40px !important}');

            // Remove the native 'power' column
            $('div.league_table div.data-row div.data-column[column=power]').remove();

            // Remove the avatars
            $('div.league_table div.data-row div.data-column[column=nickname] div.square-avatar-wrapper').remove();
        });
    }

    showHideUnhideButton() {
        this.insertRule('#beaten_opponents2 {font-size: 12px; margin-left: 5px}');

        const hide = JSON.parse(storage.getItem(FOUGHT_OPPONENTS_HIDDEN)) || false;
        const caption = hide ? "Show" : "Hide";

        this.hideUnhide(hide);

        const btn = $(`<a id="beaten_opponents2" class="blue_button_L">${caption}</a>`);
        btn.on('click', () => {
            const next = !JSON.parse(storage.getItem(FOUGHT_OPPONENTS_HIDDEN));

            this.hideUnhide(next);
            storage.setItem(FOUGHT_OPPONENTS_HIDDEN, next);
            btn.text(next ? "Show" : "Hide");
        });

        $('div.league_buttons').append(btn);
    }

    hideUnhide(hide) {
        $('div.league_table')
            .find('div.body-row')
            .each(function(index) {
                const results = $(this).find('div.data-column[column=match_history]').find('div[class!="result "]').length;
                const fought_all = results == 3;
                if (fought_all && hide) {
                    $(this).hide();
                } else if (fought_all && !hide) {
                    $(this).show();
                }
            });

        $('#leagues .league_content .league_table').getNiceScroll().resize();
    }
}

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 4
Link to comment
Share on other sites

Works great. I see you removed the (original) power column, which I found a helpful orientation, also for sorting. But it was a necessity with the additional team power and E[X] columns. And now that E[X] is very close to exact, it supersedes the power value very good. Sorting by this would be nice.

There are still glitches in combination with MM's script, when sorting, hiding finished opponents etc, but I think there is no point investing too much time in making and keeping those two fully compatible. In the end I hope Zoo (and Tom) finds time and mood to implement the features into HH++, step by step.

Edited by Horsting
Link to comment
Share on other sites

Does anyone feel like taking over the Harem++ script after Liliat quits?
It's a great script, it still works very well even if it should be implemented with the new skills.
I love that script and the idea that with future updates it stops working terrifies me, probably now that I'm used to the update speed of the harem I would seriously consider quitting the game.

  • Like 2
Link to comment
Share on other sites

19 minutes ago, renalove said:

A new battle simulator that supports girl skills has been completed. 😁

Niice, the tier 4 skill is the part still missing in all other scripts. And the difference is not exactly small. Here an example with 3 maxed girls (2 common ones, but still):

image.png.a3c2fc2fefa53593d4713c77f29abdba.png

The skills are are a little OP IMO.

Edited by Horsting
  • Like 2
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...