Hentai Heroes+++

Few QoL improvement and additions for competitive PvP players.

  1. // ==UserScript==
  2. // @name Hentai Heroes+++
  3. // @description Few QoL improvement and additions for competitive PvP players.
  4. // @version 0.20.1
  5. // @match https://*.hentaiheroes.com/*
  6. // @match https://nutaku.haremheroes.com/*
  7. // @match https://*.gayharem.com/*
  8. // @match https://*.comixharem.com/*
  9. // @match https://*.hornyheroes.com/*
  10. // @match https://*.pornstarharem.com/*
  11. // @match https://*.transpornstarharem.com/*
  12. // @match https://*.gaypornstarharem.com/*
  13. // @run-at document-body
  14. // @namespace https://gitlab.com/hentaiheroes/hh-plus-plus-plus
  15. // @grant none
  16. // @license MIT
  17. // @author 430i
  18. // ==/UserScript==
  19.  
  20.  
  21. const {$, location, localStorage: storage} = window;
  22.  
  23. // localStorage keys
  24. const LS_CONFIG_NAME = 'HHPlusPlusPlus'
  25. const LEAGUE_BASE_KEY = LS_CONFIG_NAME + ".League";
  26. const LEAGUE_SNAPSHOT_BASE_KEY = LEAGUE_BASE_KEY + ".Snapshot";
  27. const CURRENT_LEAGUE_SNAPSHOT_KEY = LEAGUE_SNAPSHOT_BASE_KEY + ".Current";
  28. const PREVIOUS_LEAGUE_SNAPSHOT_KEY = LEAGUE_SNAPSHOT_BASE_KEY + ".Previous";
  29. const LEAGUE_PLAYERS_KEY = LEAGUE_BASE_KEY + ".Players";
  30. const EQUIPMENT_KEY = LS_CONFIG_NAME + ".Equipment";
  31. const EQUIPMENT_CURRENT_KEY = EQUIPMENT_KEY + ".Current";
  32. const EQUIPMENT_BEST_MYTHIC_KEY = EQUIPMENT_KEY + ".Mythic";
  33.  
  34. // 3rd party localStorage keys
  35. const LS_CONFIG_HHPLUSPLUS_NAME = 'HHPlusPlus'
  36. const HHPLUSPLUS_OPPONENT_FILTER = LS_CONFIG_HHPLUSPLUS_NAME + "OpponentFilter"
  37.  
  38. // CONFIG
  39. const MAX_NUM_SNAPSHOTS = 230;
  40. const BOOSTER_EXPIRATION_MULTIPLIER = 1.01;
  41.  
  42. // icon paths
  43. 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"/>';
  44. 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"/>';
  45. 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"/>';
  46.  
  47. class EquipmentCollector {
  48. static collect() {
  49. if (!HHPlusPlus.Helpers.isCurrentPage('shop')) {
  50. return;
  51. }
  52.  
  53. HHPlusPlus.Helpers.defer(() => {
  54. setTimeout(() => {
  55. EquipmentCollector.collectEquipmentData();
  56. }, 250);
  57.  
  58. HHPlusPlus.Helpers.onAjaxResponse(/action=market_equip_armor*/, EquipmentCollector.onEquipmentChange);
  59. });
  60. }
  61.  
  62. static onEquipmentChange(data) {
  63. // We need to delay the execution a bit to give chance to the native callback to run.
  64. setTimeout(() => {
  65. // The is a bug in HH (quelle surprise) when swapping equipments - the native callbacks adds the
  66. // unequipped armor to the back of the inventory ('player_inventory'), but the equipped armor is not
  67. // removed, which leaves an inconsistent state, so we need to remove it ourselves.
  68. EquipmentCollector.removeEquippedArmorFromInventory(data.equipped_armor);
  69. EquipmentCollector.collectEquipmentData();
  70. }, 100);
  71. }
  72.  
  73. static removeEquippedArmorFromInventory(equipped) {
  74. const idx = player_inventory.armor.findIndex(e => {
  75. return e.item.rarity == "mythic" &&
  76. e.resonance_bonuses.class.bonus == equipped.resonance_bonuses.class.bonus &&
  77. e.resonance_bonuses.class.identifier == equipped.resonance_bonuses.class.identifier &&
  78. e.resonance_bonuses.class.resonance == equipped.resonance_bonuses.class.resonance &&
  79. e.resonance_bonuses.theme.bonus == equipped.resonance_bonuses.theme.bonus &&
  80. e.resonance_bonuses.theme.identifier == equipped.resonance_bonuses.theme.identifier &&
  81. e.resonance_bonuses.theme.resonance == equipped.resonance_bonuses.theme.resonance &&
  82. e.skin.id_item_skin === equipped.skin.id_item_skin &&
  83. e.skin.id_skin_set === equipped.skin.id_skin_set &&
  84. e.skin.identifier === equipped.skin.identifier &&
  85. e.skin.name === equipped.skin.name &&
  86. e.skin.subtype === equipped.skin.subtype;
  87. });
  88.  
  89. // player_inventory.armor[idx] = data.unequipped_armor;
  90. player_inventory.armor.splice(idx, 1);
  91. }
  92.  
  93. static collectEquipmentData() {
  94. EquipmentCollector.collectPlayerEquipment();
  95. EquipmentCollector.collectBestMythicEquipment();
  96. }
  97.  
  98. static collectPlayerEquipment() {
  99. const eqElements = $("div#equiped.armor-container div.slot:not(:empty)[subtype!='0']");
  100. if (eqElements.length != 6) {
  101. console.log("Did not find 6 equipment elements.");
  102. return;
  103. }
  104.  
  105. const equipment = eqElements.map(function() { return $(this).data("d")}).get();
  106. const equipmentStripped = equipment.map((e) => {
  107. return {
  108. id: e.id_member_armor_equipped || e.id_member_armor, // unique item identifier?
  109. rarity: e.item.rarity, // legendary, mythic
  110. type: e.item.type, // always "armor"
  111. skin_id: e.skin.identifier, // EH13, ET21 etc
  112. subtype: parseInt(e.skin.subtype), // 1, 2, 3, 4, 5 or 6
  113. carac1: parseInt(e.caracs.carac1),
  114. carac2: parseInt(e.caracs.carac2),
  115. carac3: parseInt(e.caracs.carac3),
  116. harmony: parseInt(e.caracs.chance),
  117. endurance: parseInt(e.caracs.endurance),
  118. bonuses: e.resonance_bonuses,
  119. };
  120. });
  121.  
  122. window.localStorage.setItem(EQUIPMENT_CURRENT_KEY, JSON.stringify(equipmentStripped));
  123. }
  124.  
  125. static collectBestMythicEquipment() {
  126. const hero = window.Hero ?? shared.Hero;
  127.  
  128. const equipment = player_inventory.armor
  129. .filter(a => a.item.rarity == "mythic")
  130. .filter(a => parseInt(a.resonance_bonuses.class.identifier) == hero.infos.class)
  131. .filter(a => a.resonance_bonuses.class.resonance == "damage")
  132. .filter(a => a.resonance_bonuses.theme.resonance == "defense");
  133.  
  134. window.localStorage.setItem(EQUIPMENT_BEST_MYTHIC_KEY, JSON.stringify(equipment));
  135. }
  136.  
  137. static getCurrent() {
  138. return JSON.parse(window.localStorage.getItem(EQUIPMENT_CURRENT_KEY)) || [];
  139. }
  140.  
  141. static getBestMythic() {
  142. return JSON.parse(window.localStorage.getItem(EQUIPMENT_BEST_MYTHIC_KEY)) || [];
  143. }
  144. }
  145.  
  146. class LeaguePlayersCollector {
  147. static collect() {
  148. if (!HHPlusPlus.Helpers.isCurrentPage('leagues')) {
  149. return;
  150. }
  151.  
  152. HHPlusPlus.Helpers.defer(() => {
  153. HHPlusPlus.Helpers.onAjaxResponse(/action=fetch_hero&id=profile/, LeaguePlayersCollector.collectPlayerPlacementsFromAjaxResponse);
  154. LeaguePlayersCollector.collectPlayerData();
  155. });
  156. }
  157.  
  158. static collectPlayerPlacementsFromAjaxResponse(response, opt) {
  159. // If you are reading this, please look away, ugly code below
  160. // The mythic equipment data is actually not in the html, but in the form of a script that we have to eval
  161. const html = $("<div/>").html(response.html);
  162. $.globalEval(html.find('script').text()); // creates 'hero_items'
  163.  
  164. const id = html.find("div.ranking_stats .id").text().match(/\d+/)[0];
  165. const username = html.find(".hero_info h3 .hero-name").text();
  166. const level = html.find('div[hero="level"]').text().trim();
  167. const number_mythic_equipment = Object.values(hero_items).filter(i => i.item.rarity == "mythic").length;
  168. const d3_placement = $("<div/>")
  169. .html(html)
  170. .find('div.history-independent-tier:has(img[src*="/9.png"]) span') // 9.png is D3
  171. .map(function() {return parseInt($(this).text().trim().match(/\d+/));})
  172. .get();
  173.  
  174. if (!id || !username || !level) {
  175. window.popup_message("Error when parsing player data.");
  176. return;
  177. }
  178.  
  179. if (!d3_placement || d3_placement.length != 2) {
  180. // make sure our parser is working by checking the D2 data
  181. const d2_placement = $("<div/>")
  182. .html(html)
  183. .find('div.history-independent-tier:has(img[src*="/8.png"]) span') // 8.png is D2
  184. .map(function() {return parseInt($(this).text().trim().match(/\d+/));})
  185. .get();
  186.  
  187. if (d2_placement.length != 2) {
  188. window.popup_message("Error when parsing D2 player data.");
  189. }
  190.  
  191. d3_placement.push(-1, 0);
  192. }
  193.  
  194. const data = {
  195. id: parseInt(id),
  196. number_mythic_equipment,
  197. best_placement: d3_placement[0],
  198. placement_count: d3_placement[1],
  199. };
  200.  
  201. LeaguePlayersCollector.storePlayerData(data);
  202. $(document).trigger('player:update-profile-data', {id: data.id})
  203. }
  204.  
  205. static collectPlayerData() {
  206. for (var r = 0, n = window.opponents_list.length; r < n; r++) {
  207. const player = window.opponents_list[r];
  208.  
  209. const girls = player.player.team.girls;
  210. const girl_levels = girls.map(g => g.level);
  211. const girl_levels_max = Math.max(...girl_levels);
  212. const girl_levels_total = girl_levels.reduce((a, b) => a + b, 0);
  213. const girl_levels_avg = Math.floor(girl_levels_total / girl_levels.length);
  214.  
  215. const data = {
  216. id: parseInt(player.player.id_fighter),
  217. username: player.player.nickname,
  218. level: parseInt(player.player.level),
  219. damage: player.player.damage,
  220. defense: player.player.defense,
  221. harmony: player.player.chance,
  222. ego: player.player.remaining_ego,
  223. power: player.player.team.total_power,
  224. club_id: player.player.club?.id_club,
  225. club_name: `"${player.player.club?.name || ''}"`,
  226. girl_levels_avg,
  227. girl_levels_max,
  228. }
  229.  
  230. LeaguePlayersCollector.storePlayerData(data);
  231. }
  232. }
  233.  
  234. static storePlayerData(data) {
  235. const players = JSON.parse(storage.getItem(LEAGUE_PLAYERS_KEY)) || {};
  236. if (players[data.id] == undefined) {
  237. players[data.id] = {};
  238. }
  239.  
  240. Object.assign(players[data.id], data);
  241.  
  242. storage.setItem(LEAGUE_PLAYERS_KEY, JSON.stringify(players));
  243. }
  244.  
  245. static export() {
  246. const columns = [
  247. "id",
  248. "username",
  249. "level",
  250. "damage",
  251. "defense",
  252. "harmony",
  253. "ego",
  254. "power",
  255. "club_id",
  256. "club_name",
  257. "girl_levels_max",
  258. "girl_levels_avg",
  259. "expected_points",
  260. "number_mythic_equipment",
  261. "best_placement",
  262. "placement_count",
  263. ]
  264.  
  265. const players = JSON.parse(storage.getItem(LEAGUE_PLAYERS_KEY)) || {};
  266. const data = Object.values(players).map(player => columns.map(column => player[column]));
  267.  
  268. console.log([columns].concat(data).map(t => t.join(",")).join("\n"));
  269. }
  270.  
  271. static clear() {
  272. storage.removeItem(LEAGUE_PLAYERS_KEY);
  273. }
  274. }
  275.  
  276. class MyModule {
  277. constructor ({name, configSchema}) {
  278. this.group = '430i'
  279. this.name = name
  280. this.configSchema = configSchema
  281. this.hasRun = false
  282.  
  283. this.insertedRuleIndexes = []
  284. this.sheet = HHPlusPlus.Sheet.get()
  285. }
  286.  
  287. insertRule (rule) {
  288. this.insertedRuleIndexes.push(this.sheet.insertRule(rule))
  289. }
  290.  
  291. tearDown () {
  292. this.insertedRuleIndexes.sort((a, b) => b-a).forEach(index => {
  293. this.sheet.deleteRule(index)
  294. })
  295.  
  296. this.insertedRuleIndexes = []
  297. this.hasRun = false
  298. }
  299. }
  300.  
  301. class LeagueScoutModule extends MyModule {
  302. constructor () {
  303. const baseKey = 'leagueScout'
  304. const configSchema = {
  305. baseKey,
  306. default: true,
  307. label: `Gather information about league opponents`,
  308. }
  309. super({name: baseKey, configSchema})
  310. }
  311.  
  312. shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('leagues')}
  313.  
  314. run () {
  315. if (this.hasRun || !this.shouldRun()) {return}
  316.  
  317. $(document).on('league:rollover', () => {
  318. const data = LeagueScoutModule.getCurrent();
  319.  
  320. LeagueScoutModule.deleteCurrent();
  321. LeagueScoutModule.setPrevious(data);
  322. LeaguePlayersCollector.clear();
  323. })
  324.  
  325. HHPlusPlus.Helpers.defer(() => {
  326. // read and store data
  327. this.storeSnapshot(this.readSnapshot());
  328.  
  329. // create ui elements
  330. HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_buttons_block', () => {
  331. const parent = $('div.league_buttons');
  332. this.createDownloadButton(parent, PREVIOUS_LEAGUE_SNAPSHOT_KEY, PATH_GROUPS);
  333. this.createClearButton(parent, PREVIOUS_LEAGUE_SNAPSHOT_KEY);
  334. this.createDownloadButton(parent, CURRENT_LEAGUE_SNAPSHOT_KEY, PATH_GROUP);
  335. this.createClearButton(parent, CURRENT_LEAGUE_SNAPSHOT_KEY);
  336. });
  337. });
  338.  
  339. this.hasRun = true;
  340. }
  341.  
  342. readPlayerData() {
  343. const data = {};
  344.  
  345. for (var r = 0, n = window.opponents_list.length; r < n; r++) {
  346. const player = window.opponents_list[r];
  347.  
  348. const id = parseInt(player.player.id_fighter);
  349. const name = player.player.nickname;
  350. const country = player.country;
  351. const level = parseInt(player.player.level);
  352.  
  353. const entry = {id, name, country, level};
  354. if (Object.values(entry).some(x => x == undefined || (typeof x !== 'string' && !Array.isArray(x) && isNaN(x)))) {
  355. console.log('Some player data is missing, maybe the opponents_list data structure changed?');
  356. console.log(entry);
  357. }
  358.  
  359. data[id] = {name, country, level};
  360. }
  361.  
  362. return data;
  363. }
  364.  
  365. readSnapshot() {
  366. const data = [];
  367.  
  368. for (var r = 0, n = window.opponents_list.length; r < n; r++) {
  369. const player = window.opponents_list[r];
  370.  
  371. const id = parseInt(player.player.id_fighter);
  372. const rank = player.place;
  373. const points = parseInt(player.player_league_points);
  374. const elements = player.player.team.theme;
  375.  
  376. const damage = player.player.damage;
  377. const defense = player.player.defense;
  378. const ego = player.player.remaining_ego;
  379. const chance = player.player.chance;
  380. const power = player.player.team.total_power;
  381.  
  382. // Take only the first two chars of the booster names, as those should be unique. Assume all are legendary.
  383. const boosters = player.boosters.filter(b => b.expiration > 0).map(b => b.item.name.slice(0, 2))
  384.  
  385. // Create player snapshot and validate it.
  386. const entry = {id, rank, elements, points, power, damage, defense, ego, chance, boosters};
  387. if (Object.values(entry).some(x => x == undefined || (typeof x !== 'string' && !Array.isArray(x) && isNaN(x)))) {
  388. console.log('Some player data is missing, maybe the opponents_list data structure changed?');
  389. console.log(entry);
  390. }
  391.  
  392. data.push(entry);
  393. }
  394.  
  395. // Sort the parsed data by rank.
  396. data.sort((a, b) => a.rank > b.rank);
  397.  
  398. return data;
  399. }
  400.  
  401. storeSnapshot(snapshot_data) {
  402. var data = LeagueScoutModule.getCurrent();
  403.  
  404. const current_date = new Date(window.server_now_ts * 1000);
  405. const league_end_date = new Date(window.server_now_ts * 1000 + window.season_end_at * 1000);
  406.  
  407. // Create the initial container data structure
  408. if (Object.keys(data).length == 0) {
  409. data = {
  410. league_end: league_end_date,
  411. num_players: data.length,
  412. player_data: this.readPlayerData(),
  413. snapshots: [],
  414. }
  415. }
  416.  
  417. const snapshot = {
  418. date: current_date,
  419. snapshot: snapshot_data,
  420. }
  421.  
  422. if (data.snapshots.length && JSON.stringify(data.snapshots[data.snapshots.length - 1].snapshot) === JSON.stringify(snapshot.snapshot)) {
  423. return;
  424. }
  425.  
  426. if (data.snapshots.length >= MAX_NUM_SNAPSHOTS) {
  427. var previous = LeagueScoutModule.getPrevious();
  428.  
  429. // delete an entry either from the previous league snapshots or from the current
  430. if (previous && previous.snapshots && previous.snapshots.length > 0) {
  431. previous.snapshots.shift()
  432. LeagueScoutModule.setPrevious(previous);
  433. } else {
  434. data.snapshots.shift();
  435. }
  436. }
  437.  
  438. data.snapshots.push(snapshot);
  439. LeagueScoutModule.setCurrent(data);
  440. }
  441.  
  442. createButton(id, path) {
  443. return `<svg id="${id}" class="blue_button_L" width="32" height="32" style="padding: 5px" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="#FFFFFF"><g><rect fill="none" height="24" width="24"/></g><g>${path}</g></svg>`
  444. }
  445.  
  446. createDownloadButton(parent, what, icon) {
  447. if (!storage.getItem(what)) {
  448. return;
  449. }
  450.  
  451. const friendlyId = what.toLowerCase().replaceAll(".", "-");
  452. const buttonId = `download-${friendlyId}`;
  453.  
  454. const downloadButton = this.createButton(buttonId, icon);
  455. parent.append(downloadButton);
  456.  
  457. $(document.body).on('click', `#${buttonId}`, () => {
  458. const data = LeagueScoutModule.get(what);
  459.  
  460. const separator = ","
  461. const columns = ["date", "player_id", "player_name", "player_rank", "player_points", "player_power", "player_damage", "player_defense", "player_ego", "player_chance", "player_boosters"];
  462. const values = data.snapshots.flatMap((e) => e.snapshot.map((p) => [e.date, p.id, data.player_data[p.id].name, p.rank, p.points, p.power, p.damage, p.defense, p.ego, p.chance, `"${p.boosters.join(",")}"`].join(separator)));
  463.  
  464. let csvContent = `sep=${separator}\n` + columns.join(separator) + "\n" + values.join("\n");
  465.  
  466. var element = document.createElement('a');
  467. element.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(csvContent));
  468. element.setAttribute('download', `${friendlyId}.csv`);
  469.  
  470. element.style.display = 'none';
  471. document.body.appendChild(element);
  472.  
  473. element.click();
  474.  
  475. document.body.removeChild(element);
  476. });
  477. }
  478.  
  479. createClearButton(parent, what) {
  480. if (!storage.getItem(what)) {
  481. return;
  482. }
  483.  
  484. const friendlyId = what.toLowerCase().replaceAll(".", "-");
  485. const buttonId = `clear-${friendlyId}`;
  486.  
  487. const clearButton = this.createButton(buttonId, PATH_CLEAR);
  488. parent.append(clearButton);
  489.  
  490. $(document.body).on('click', `#${buttonId}`, () => {
  491. storage.removeItem(what);
  492. });
  493. }
  494.  
  495. static getCurrent() {
  496. return LeagueScoutModule.get(CURRENT_LEAGUE_SNAPSHOT_KEY);
  497. }
  498.  
  499. static getPrevious() {
  500. return LeagueScoutModule.get(PREVIOUS_LEAGUE_SNAPSHOT_KEY);
  501. }
  502.  
  503. static get(key) {
  504. var data = JSON.parse(storage.getItem(key)) || {};
  505.  
  506. // Migrate from the old data structure
  507. if (Array.isArray(data) && data.length > 0) {
  508. const last_snapshot = data[data.length - 1];
  509.  
  510. const reduceP = ({id, name, country, level}) => ({id, name, country, level});
  511. const reduceS = ({id, rank, elements, points, damage, defense, ego, chance, boosters}) => ({id, rank, elements, points, damage, defense, ego, chance, boosters});
  512.  
  513. const player_data = last_snapshot.player_data.map(reduceP).reduce((map, obj) => {
  514. map[obj.id] = obj;
  515. return map;
  516. }, {});
  517.  
  518. const snapshots = data.map(d => ({date: d.date, snapshot: d.player_data.map(reduceS)}));
  519.  
  520. data = {
  521. league_end: last_snapshot.league_end,
  522. num_players: last_snapshot.num_players,
  523. player_data,
  524. snapshots,
  525. }
  526. }
  527.  
  528. return data;
  529. }
  530.  
  531. static deleteCurrent() {
  532. storage.removeItem(CURRENT_LEAGUE_SNAPSHOT_KEY);
  533. }
  534.  
  535. static setCurrent(data) {
  536. this.set(CURRENT_LEAGUE_SNAPSHOT_KEY, data);
  537. }
  538.  
  539. static setPrevious(data) {
  540. this.set(PREVIOUS_LEAGUE_SNAPSHOT_KEY, data);
  541. }
  542.  
  543. static set(key, data) {
  544. if (data.snapshots.length > 0) {
  545. storage.setItem(key, JSON.stringify(data));
  546. } else {
  547. storage.removeItem(key);
  548. }
  549. }
  550. }
  551.  
  552. class LeagueTableModule extends MyModule {
  553. constructor () {
  554. const baseKey = 'leagueTable'
  555. const configSchema = {
  556. baseKey,
  557. default: true,
  558. label: `Extend league table with additional opponents' information`,
  559. subSettings: [
  560. {
  561. key: 'girl_power',
  562. label: 'Show girl power in the league table',
  563. default: false
  564. },
  565. {
  566. key: 'kinkoid_power',
  567. label: 'Show the new power stat in the league table',
  568. default: false
  569. },
  570. {
  571. key: 'number_of_bulbs',
  572. label: 'Show the number of invested bulbs in the league table',
  573. default: true
  574. },
  575. {
  576. key: 'load_player_data',
  577. label: 'Load player data on league table row click',
  578. default: true
  579. },
  580. ],
  581. }
  582. super({name: baseKey, configSchema})
  583.  
  584. this.all_new_columns = ['kinkoid_power', 'girl_power', 'number_of_bulbs'];
  585. this.anchor_column = 'power';
  586. }
  587.  
  588. shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('leagues')}
  589.  
  590. run(config) {
  591. if (this.hasRun || !this.shouldRun()) {return}
  592.  
  593. HHPlusPlus.Helpers.defer(() => {
  594. HHPlusPlus.Helpers.doWhenSelectorAvailable('.league_table', () => {
  595. this.extendLeagueDataModel();
  596. this.addPlayerSelectHandler(config);
  597. this.showPlayersPlacementBadge(config);
  598. this.showAdditionalTableHeaders(config);
  599. this.showAdditionalTableColumns(config);
  600. this.showMaxPointsTooltip();
  601. this.detectReallyExpiredBoosers();
  602. this.detectInflatedPower();
  603. this.makeCompatibleWithLeaguePlusPlus();
  604. });
  605.  
  606. $(document).on('player:update-profile-data', (event, data) => {
  607. this.extendLeagueDataModel();
  608. this.showPlayersPlacementBadge(config);
  609. });
  610.  
  611. $(document).on('league:table-sorted', () => {
  612. this.showPlayersPlacementBadge(config);
  613. this.showAdditionalTableColumns(config);
  614. this.showMaxPointsTooltip(config);
  615. this.detectReallyExpiredBoosers();
  616. this.detectInflatedPower();
  617. this.makeCompatibleWithLeaguePlusPlus();
  618. });
  619. });
  620.  
  621. this.hasRun = true;
  622. }
  623.  
  624. extendLeagueDataModel() {
  625. const players_data = JSON.parse(storage.getItem(LEAGUE_PLAYERS_KEY)) || {};
  626.  
  627. // add power to the existing `opponents_list` data model
  628. for (var r = 0, n = opponents_list.length; r < n; r++) {
  629. const player = opponents_list[r];
  630. const id = parseInt(player.player.id_fighter);
  631.  
  632. const player_data = players_data[id];
  633. const best_placement = player_data != undefined ? player_data.best_placement : -1;
  634. const placement_count = player_data != undefined ? player_data.placement_count : -1;
  635.  
  636. player.best_placement = best_placement;
  637. player.placement_count = placement_count;
  638.  
  639. player.kinkoid_power = number_reduce(player.player.team.power_display);
  640. player.girl_power = player.player.team.total_power.toFixed();
  641. player.number_of_bulbs = player.player.team.girls.flatMap(g => Object.values(g.skill_tiers_info)).reduce((a,g)=>a+g.skill_points_used, 0)
  642. }
  643. }
  644.  
  645. showAdditionalTableHeaders(config) {
  646. // Additional CSS classes
  647. const row_styles = {kinkoid_power: '2rem', girl_power: '2.2rem', number_of_bulbs: '0.9rem'}
  648. for (const [clazz, min_width] of Object.entries(row_styles)) {
  649. // this.insertRule(`.league_table .data-row .head-column[column="${clazz}"] {display: flex; align-items: center; justify-content: center}`);
  650. this.insertRule(`.league_table .data-row .data-column[column="${clazz}"] {min-width: ${min_width}}`);
  651. }
  652.  
  653. const columns = this.all_new_columns.filter(c => config[c]);
  654.  
  655. const headers = {
  656. kinkoid_power: `<span>${GT.design.caracs_sum}</span>`,
  657. girl_power: `<span>${GT.design.total_power}</span>`, // <span class="upDownArrows_mix_icn">
  658. number_of_bulbs: '<span class="scrolls_legendary_icn"></span>',
  659. }
  660.  
  661. $(`div.league_table div.head-row div.head-column[column=${this.anchor_column}]`).after(
  662. columns.map(c => `<div class="data-column head-column" column="${c}">${headers[c]}</div>`).join('')
  663. );
  664.  
  665. }
  666.  
  667. showAdditionalTableColumns(config) {
  668. this.insertRule(`.active_skill {color: red; text-shadow: 1px 1px 0 #000, -1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000;}`);
  669.  
  670. const context = this;
  671.  
  672. $('div.league_table')
  673. .find('div.body-row')
  674. .each(function(index) {
  675. const opponent = window.opponents_list[index];
  676. const columns = context.all_new_columns.filter(c => config[c]);
  677.  
  678. $(this).find(`div.data-column[column=${context.anchor_column}]`).after(
  679. columns.map(c => `<div class="data-column" column="${c}"><div class="${context.tableColumnClass(c, opponent)}">${opponent[c]}</div></div>`).join('')
  680. );
  681. })
  682. }
  683.  
  684. tableColumnClass(column, opponent) {
  685. if (!column.includes('bulb')) {
  686. return '';
  687. }
  688.  
  689. const clazz = 'active_skill'; // or active_skills_icn
  690.  
  691. const active_skills = opponent.player.team.girls[0].skill_tiers_info[5];
  692. return active_skills && active_skills.skill_points_used > 0 ? clazz : '';
  693. }
  694.  
  695. showMaxPointsTooltip() {
  696. const data = LeagueScoutModule.getCurrent();
  697.  
  698. if (!data || !data.snapshots.length) {
  699. return;
  700. }
  701.  
  702. $('.league_table .body-row').each(function (idx) {
  703. const opponent = opponents_list[idx];
  704. const opponent_id = parseInt(opponent.player.id_fighter);
  705.  
  706. const points = data.snapshots.map(d => d.snapshot.find(p => p.id == opponent_id).points);
  707.  
  708. const remainder = points[0] % 25;
  709. var lost_points = (remainder == 0 ? 0 : 25 - remainder);
  710.  
  711. for (let i = 0; i < points.length - 1; i+=1) {
  712. const diff = points[i+1] - points[i];
  713. const remainder = diff % 25;
  714. lost_points += (remainder == 0 ? 0 : 25 - remainder);
  715. }
  716.  
  717. const element = $(this).find('.data-column[column=player_league_points]');
  718. element.attr('tooltip', `Lost points: ${lost_points}`);
  719. });
  720. }
  721.  
  722. detectReallyExpiredBoosers() {
  723. const data = LeagueScoutModule.getCurrent();
  724.  
  725. if (!data || !data.snapshots.length) {
  726. return;
  727. }
  728.  
  729. $('.league_table .body-row').each(function (idx) {
  730. const opponent = opponents_list[idx];
  731. const opponent_id = parseInt(opponent.player.id_fighter);
  732.  
  733. const is_currently_boosted = opponent.boosters.some(b => b.expiration > 0);
  734. if (is_currently_boosted) {
  735. return;
  736. }
  737.  
  738. const snapshot_data = data.snapshots.map(d => d.snapshot.find(p => p.id == opponent_id)).reverse();
  739. const boosted_data = snapshot_data.find(p => p.boosters && p.boosters.length >= 3);
  740. if (!boosted_data) {
  741. return;
  742. }
  743.  
  744. if (
  745. (opponent.player.damage * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.damage &&
  746. (opponent.player.defense * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.defense &&
  747. (opponent.player.remaining_ego * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.ego &&
  748. (opponent.player.chance * BOOSTER_EXPIRATION_MULTIPLIER) >= boosted_data.chance
  749. ) {
  750. const element = $(this).find('.data-column[column=boosters]');
  751. element.addClass('active_skill');
  752. }
  753. });
  754. }
  755.  
  756. detectInflatedPower(take_n = 30) {
  757. const data = LeagueScoutModule.getCurrent();
  758.  
  759. if (!data || !data.snapshots.length) {
  760. return;
  761. }
  762.  
  763. $('.league_table .body-row').each(function (idx) {
  764. const opponent = opponents_list[idx];
  765. const opponent_id = parseInt(opponent.player.id_fighter);
  766. const current_power = opponent.player.team.total_power;
  767.  
  768. const snapshot_data = data.snapshots.map(d => d.snapshot.find(p => p.id == opponent_id));
  769. const latest_power = snapshot_data.map(x => x.power).filter(x => x).slice(-1 * take_n);
  770. const lowest_power = Math.min(...latest_power);
  771.  
  772. if (current_power > lowest_power) {
  773. const element = $(this).find('.data-column[column=team] span.team-power');
  774. element.addClass('active_skill');
  775. element.attr('tooltip', `Previously: ${lowest_power}`);
  776. }
  777. });
  778. }
  779.  
  780. showPlayersPlacementBadge(config) {
  781. if (!config.load_player_data) {
  782. return;
  783. }
  784.  
  785. // Additional CSS classes
  786. this.insertRule('.badge.top1 {background-color:#ec0039}');
  787. this.insertRule('.badge.top1::after {content:"1"}');
  788.  
  789. for (let i = 2; i <= 4; i++) {
  790. // this.insertRule(`.badge.top${i} {background-color:#8e36a9}`);
  791. this.insertRule(`.badge.top${i} {background:var(--legendary-bg);background-size:cover}`);
  792. this.insertRule(`.badge.top${i}::after {content:"${i}"}`);
  793. }
  794.  
  795. const context = this;
  796.  
  797. $('.league_table .body-row').each(function (idx) {
  798. const opponent = opponents_list[idx];
  799. if (opponent.best_placement != undefined) {
  800. context.updatePlayerPlacementBadge($(this), opponent);
  801. }
  802. });
  803. }
  804.  
  805. addPlayerSelectHandler(config) {
  806. if (!config.load_player_data) {
  807. return;
  808. }
  809.  
  810. // Remove the go_pre_battle class to allow users to select the row (inspired by Leagues++)
  811. $('.league_table .data-column[column=can_fight] .go_pre_battle').removeClass('go_pre_battle');
  812.  
  813. $('.league_table .body-row').on('click', function() {
  814. const element_nickname = $(this).find('.data-column[column=nickname] span.nickname')
  815. const player_id = element_nickname.attr('id-member');
  816.  
  817. const opponent = window.opponents_list.find(x => parseInt(x.player.id_fighter) == player_id);
  818. if (opponent.best_placement != undefined) {
  819. return false;
  820. }
  821.  
  822. window.$.post({
  823. url: '/ajax.php',
  824. data: {
  825. action: 'fetch_hero',
  826. id: 'profile',
  827. preview: false,
  828. player_id: parseInt(player_id),
  829. },
  830. success: (data) => {}
  831. });
  832.  
  833. return false;
  834. });
  835. }
  836.  
  837. updatePlayerPlacementBadge(row, player_data) {
  838. const nicknameElement = row.find('div.data-column[column=nickname]');
  839.  
  840. var badgeContainer = nicknameElement.find('.badge-container');
  841. if (!badgeContainer.length) {
  842. badgeContainer = $('<div class="badge-container" />').appendTo(nicknameElement);
  843. }
  844.  
  845. // best placement indicator next to the nickname
  846. badgeContainer.html(this.createBestPlacementBadge(player_data));
  847. }
  848.  
  849. createBestPlacementBadge(player) {
  850. if (player.best_placement < 1 || player.best_placement > 4) {
  851. return ''
  852. }
  853.  
  854. const clazz = `top${player.best_placement}`;
  855. return `<span class="best-placement"><span class="scriptLeagueInfoIcon badge ${clazz}"></span>${player.placement_count}</span>`;
  856. }
  857.  
  858. makeCompatibleWithLeaguePlusPlus() {
  859. HHPlusPlus.Helpers.doWhenSelectorAvailable('div#leagues div.league_buttons a#change_team', () => {
  860. // Remove the avatars
  861. $('div.league_table div.data-row div.data-column[column=nickname] div.square-avatar-wrapper').remove();
  862.  
  863. // Hide row when opponent has been fought
  864. const context = this;
  865. $('body').on('DOMSubtreeModified', '.league_table .body-row .data-column[column=match_history_sorting]', function() {
  866. context.hideUnhideRow($(this).parent('div.body-row'), context.isHideOpponents());
  867. });
  868. });
  869. }
  870.  
  871. hideUnhideRow(row, hide) {
  872. const results = row.find('div.data-column[column=match_history_sorting]').find('div[class!="result "]').length;
  873. const fought_all = results == 3;
  874. if (fought_all && hide) {
  875. row.hide();
  876. } else if (fought_all && !hide) {
  877. row.show();
  878. }
  879. }
  880.  
  881. isHideOpponents() {
  882. const filter = JSON.parse(storage.getItem(HHPLUSPLUS_OPPONENT_FILTER)) || {fought_opponent: false};
  883. return filter.fought_opponent;
  884. }
  885. }
  886.  
  887. class PrebattleFlightCheckModule extends MyModule {
  888. constructor () {
  889. const baseKey = 'prebattleFlightCheck'
  890. const configSchema = {
  891. baseKey,
  892. default: true,
  893. label: `Run team and equipment checks before league battles`,
  894. }
  895. super({name: baseKey, configSchema})
  896. }
  897.  
  898. shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('leagues-pre-battle') || HHPlusPlus.Helpers.isCurrentPage('leagues')}
  899.  
  900. run() {
  901. if (this.hasRun || !this.shouldRun()) {return}
  902.  
  903. HHPlusPlus.Helpers.defer(() => {
  904. if (HHPlusPlus.Helpers.isCurrentPage('leagues')) {
  905. $(document).ajaxComplete((evt, xhr, opt) => {
  906. if (xhr.status == 200 && ~opt.url.search(/\/leagues-pre-battle.html\?id_opponent=\d+/)) {
  907. const hero = window.Hero ?? shared.Hero;
  908. const me = opponents_list.find(p => parseInt(p.player.id_fighter) == hero.infos.id);
  909. const themes = me.player.team.theme_elements.map(x => x.type);
  910.  
  911. this.checkMythicEquipment(themes);
  912. }
  913. });
  914. }
  915.  
  916. if (HHPlusPlus.Helpers.isCurrentPage('leagues-pre-battle')) {
  917. HHPlusPlus.Helpers.doWhenSelectorAvailable('div.player-panel div.player-team', () => {
  918. const synergies = JSON.parse($('div.player-panel div.player-team div.icon-area').attr('synergy-data'));
  919. const themes = synergies.filter(x => x.team_girls_count >=3).map(x => x.element.type);
  920.  
  921. this.checkMythicEquipment(themes);
  922. });
  923. }
  924. });
  925.  
  926. this.hasRun = true;
  927. }
  928.  
  929. checkMythicEquipment(themes_or_empty) {
  930. // Additional CSS classes
  931. this.insertRule(`.slot.size_xxs {width:1.5rem;height:1.5rem;-webkit-border-radius:.2rem;-moz-border-radius:.2rem;border-radius:.2rem}`);
  932.  
  933. const me = EquipmentCollector.getBestMythic();
  934. const equipment_themes = me.map(x => x.resonance_bonuses.theme.identifier || 'balanced');
  935.  
  936. const themes = themes_or_empty.length ? themes_or_empty : ['balanced'];
  937.  
  938. const has_matching_me = themes.some(t => equipment_themes.includes(t));
  939. if (has_matching_me) {
  940. const tooltip = "You have a perfect mythic equipment for your team in your inventory.";
  941. $('div.opponent div.player_details').append(
  942. `<div class="slot size_xxs mythic random_equipment mythic" rarity="mythic" tooltip="${tooltip}">
  943. <span class="mythic_equipment_icn"></span>
  944. </div>`
  945. );
  946. }
  947. }
  948. }
  949.  
  950. class GirlPreviewModule extends MyModule {
  951. constructor () {
  952. const baseKey = 'girlPreviewFilters'
  953. const configSchema = {
  954. baseKey,
  955. default: true,
  956. label: `Girl preview`,
  957. subSettings: [
  958. {
  959. key: 'preview_girl_pose',
  960. label: 'Uncensor girl pose preview',
  961. default: false
  962. },
  963. ]
  964. }
  965. super({name: baseKey, configSchema})
  966. }
  967.  
  968. shouldRun() {return true;}
  969.  
  970. run ({preview_girl_pose}) {
  971. if (this.hasRun || !this.shouldRun()) {return}
  972.  
  973. HHPlusPlus.Helpers.defer(() => {
  974. if (preview_girl_pose) {
  975. this.previewGirlPose();
  976. }
  977. });
  978.  
  979. this.hasRun = true;
  980. }
  981.  
  982. previewGirlPose() {
  983. const observer = new MutationObserver(() => {
  984. HHPlusPlus.Helpers.doWhenSelectorAvailable('#girl_preview_popup', () => {
  985. $("div.pose-preview_wrapper").removeClass("locked");
  986. $("span.preview-locked_icn").remove();
  987. });
  988. })
  989. observer.observe($('#common-popups')[0], {childList: true});
  990. }
  991. }
  992.  
  993. class SeasonalEventModule extends MyModule {
  994. constructor () {
  995. const baseKey = 'seasonalEvent'
  996. const configSchema = {
  997. baseKey,
  998. default: true,
  999. label: `Seasonal event`,
  1000. }
  1001. super({name: baseKey, configSchema})
  1002. }
  1003.  
  1004. shouldRun() {return HHPlusPlus.Helpers.isCurrentPage('seasonal');}
  1005.  
  1006. run () {
  1007. if (this.hasRun || !this.shouldRun()) {return}
  1008.  
  1009. HHPlusPlus.Helpers.defer(() => {
  1010. for (const e of [50, 100, 250, 500, 1000]) {
  1011. this.insertRule(`.badge.top${e} {background-color:#333; text-align:center}`);
  1012. this.insertRule(`.badge.top${e}::after {content:"${e}"}`);
  1013. this.insertRule('.scriptLeagueInfoIcon {display: inline-block;height: 16px;width: 32px;font-size: 10px;border-radius: 5px;margin-left: 6px;margin-right: 2px;text-shadow: 0 0 1px #000;-moz-transform: rotate(0.05deg);');
  1014. this.insertRule('.leaderboard-placement {font-size: 12px;text-shadow:1px 1px 0 #000}');
  1015. }
  1016.  
  1017. HHPlusPlus.Helpers.onAjaxResponse(/action=leaderboard&feature=seasonal_event_top/, this.rankingBadges);
  1018. });
  1019.  
  1020. this.hasRun = true;
  1021. }
  1022.  
  1023. rankingBadges(leaderboard) {
  1024. HHPlusPlus.Helpers.doWhenSelectorAvailable('.ranking-timer', () => {
  1025.  
  1026. const xd = [50, 100, 250, 500, 1000].map(e => {
  1027. const diff = leaderboard.leaderboard[e - 1].potions - leaderboard.hero_data.potions + 1;
  1028. return `<span class="leaderboard-placement"><span class="scriptLeagueInfoIcon badge top${e}"></span>${diff}</span>`;
  1029. }).join('');
  1030.  
  1031. $("div.ranking-timer").append(xd);
  1032. });
  1033. }
  1034. }
  1035.  
  1036. setTimeout(() => {
  1037. const {hhPlusPlusConfig, HHPlusPlus, location} = window;
  1038.  
  1039. if (!$) {
  1040. console.log('No jQuery found. Probably an error page. Ending the script here')
  1041. return;
  1042. } else if (!hhPlusPlusConfig || !HHPlusPlus) {
  1043. console.log("HH++ is not available");
  1044. return;
  1045. } else if (location.pathname === '/' && (location.hostname.includes('www') || location.hostname.includes('test'))) {
  1046. console.log("iframe container, do nothing");
  1047. return;
  1048. }
  1049.  
  1050. // collectors
  1051. EquipmentCollector.collect();
  1052. LeaguePlayersCollector.collect();
  1053.  
  1054. // modules
  1055. const modules = [
  1056. new LeagueScoutModule(),
  1057. new LeagueTableModule(),
  1058. new PrebattleFlightCheckModule(),
  1059. new GirlPreviewModule(),
  1060. new SeasonalEventModule(),
  1061. ]
  1062.  
  1063. // register our own window hooks
  1064. window.HHPlusPlusPlus = {
  1065. exportLeagueData: LeaguePlayersCollector.export,
  1066. clearLeagueData: LeaguePlayersCollector.clear,
  1067. };
  1068.  
  1069. hhPlusPlusConfig.registerGroup({
  1070. key: '430i',
  1071. name: '430i\'s Scripts'
  1072. })
  1073.  
  1074. modules.forEach(module => hhPlusPlusConfig.registerModule(module))
  1075. hhPlusPlusConfig.loadConfig()
  1076. hhPlusPlusConfig.runModules()
  1077.  
  1078. HHPlusPlus.Helpers.runDeferred()
  1079. }, 1)