FF Progs

Adds a Button to view logs in xivanalysis also some minor improvements.

目前為 2023-05-04 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name                FF Progs
// @name:en             FF Progs
// @description         Adds a Button to view logs in xivanalysis also some minor improvements.
// @description:en      Adds a Button to view logs in xivanalysis also some minor improvements.
// @version             1.0.5
// @namespace           k_fizzel
// @author              Chad Bradly
// @website             https://www.fflogs.com/character/id/12781922
// @icon                https://assets.rpglogs.com/img/ff/favicon.png?v=2
// @match               https://*.fflogs.com/*
// @require             https://code.jquery.com/jquery-3.2.0.min.js
// @grant               unsafeWindow
// @grant               GM_addStyle
// @grant               GM_getValue
// @grant               GM_setValue
// @grant               GM_deleteValue
// @license             MIT License
// ==/UserScript==

(function () {
  "use strict";

  const JOB_ORDER = [
    // Tanks
    "Paladin",
    "Warrior",
    "DarkKnight",
    "Gunbreaker",
    // Healers
    "WhiteMage",
    "Scholar",
    "Astrologian",
    "Sage",
    // Melee
    "Monk",
    "Dragoon",
    "Ninja",
    "Samurai",
    "Reaper",
    // Physical Ranged
    "Bard",
    "Machinist",
    "Dancer",
    // Magical Ranged
    "BlackMage",
    "Summoner",
    "RedMage",
  ];
  const ABILITY_TYPES = {
    0: "None",
    1: "Buff",
    2: "Unknown",
    4: "Unknown",
    8: "Heal",
    16: "Unknown",
    32: "True",
    64: "DOT",
    124: "Darkness",
    125: "Darkness",
    126: "Darkness",
    127: "Darkness",
    128: "Physical",
    256: "Magical",
    512: "Unknown",
    1024: "Magical",
  };
  const PASSIVE_LB_GAIN = [
    ["75"], // one bar
    ["180"], // two bars
    ["220", "170", "160", "154", "144", "140"], // three bars
  ];
  const LB_PIN = `2$Off$#244F4B$expression$type = "limitbreakupdate" OR type = "calculateddamage" AND target.type = "player" OR type = "heal" AND isTick = "false"`;
  const REPORTS_PATH_REGEX = /\/reports\/.+/;
  const ZONE_RANKINGS_PATH_REGEX = /\/zone\/rankings\/.+/;
  const CHARACTER_PATH_REGEX = /\/character\/.+/;
  const PROFILE_PATH_REGEX = /\/profile/;
  const LB_REGEX = /The limit break gauge updated to (\d+). There are (\d+) total bars./;

  const apiKey = GM_getValue("apiKey");

  const getHashParams = () => {
    const hash = window.location.hash.substring(1);
    const params = {};

    hash.split("&").forEach((pair) => {
      const [key, value] = pair.split("=");
      params[key] = decodeURIComponent(value);
    });

    return params;
  };

  const changeHashParams = (defaultParams) => {
    const hashParams = getHashParams();
    const newParams = {
      ...hashParams,
      ...defaultParams,
    };

    location.hash = Object.entries(newParams)
      .filter(([_key, value]) => !["undefined", "null", "", null, undefined].includes(value))
      .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
      .join("&");
  };

  const characterAllStar = (rank, outOf, rDPS, rankOneRDPS) => {
    return Math.min(Math.max(100 * (rDPS / rankOneRDPS), 100 - (rank / outOf) * 100) + 20 * (rDPS / rankOneRDPS), 120);
  };

  // AdBlock
  $("#top-banner, .side-rail-ads, #bottom-banner, #subscription-message-tile-container, #playwire-video-container, #right-ad-box, #right-vertical-banner").remove();
  $("#table-container").css("margin", "0 0 0 0");

  // Reports Page
  if (REPORTS_PATH_REGEX.test(location.pathname)) {
    // Add XIV Analysis Button
    $("#filter-analyze-tab").before(
      `<a target="_blank" class="big-tab view-type-tab" id="xivanalysis-tab"><span class="zmdi zmdi-time-interval"></span> <span class="big-tab-text"><br>xivanalysis</span></a>`
    );
    $("#xivanalysis-tab").click(() => {
      $("#xivanalysis-tab").attr("href", `https://xivanalysis.com/report-redirect/${location.href}`);
    });

    $("#filter-type-tabs").css("cursor", "default");
    // add new tab 1 before last element
    $("#filter-type-tabs").find("a:nth-last-child(2)").after(`<a href="#" class="filter-type-tab drop" id="filter-lb-tab">LB</a>`);
    $("#filter-lb-tab").click(() => {
      changeHashParams({
        type: "summary",
        view: "events",
        pins: '2$Off$#244F4B$expression$type = "limitbreakupdate" OR type = "calculateddamage" AND target.type = "player" OR type = "heal" AND isTick = "false"',
      });
      return false;
    });

    let jobs;
    const rankOnes = {};
    let lastTimeDiff;
    let lastLbGain;
    let lastLbGainTime;

    const onTableChange = () => {
      const hashParams = getHashParams();
      // Rankings Tab
      if (hashParams.view === "rankings") {
        if (!GM_getValue("apiKey")) return;
        const rows = [];
        if (!jobs) {
          fetch(`https://www.fflogs.com/v1/classes?api_key=${GM_getValue("apiKey")}`)
            .then((res) => res.json())
            .then((data) => {
              jobs = data[0].specs;
              rows.forEach((row) => {
                updatePoints(row);
              });
            })
            .catch((err) => console.error(err));
        } else {
          setTimeout(() => {
            rows.forEach((row) => {
              updatePoints(row);
            });
          }, 0);
        }

        const updatePoints = async (row) => {
          const hashParams = getHashParams();
          const rank = Number($(row).find("td:nth-child(2)").text().replace("~", ""));
          const outOf = Number($(row).find("td:nth-child(3)").text().replace(",", ""));
          const dps = Number($(row).find("td:nth-child(6)").text().replace(",", ""));
          const jobName = $(row).find("td:nth-child(5) > a").attr("class") || "";
          const jobName2 = $(row).find("td:nth-child(5) > a:nth-last-child(1)").attr("class") || "";
          const playerMetric = hashParams.playermetric || "rdps";

          if (jobName2 !== "players-table-realm") {
            $(row)
              .find("td:nth-child(7)")
              .html(`<center><img src="https://cdn.7tv.app/emote/62523dbbbab59cfd1b8b889d/1x.webp" title="No api v1 endpoint for combined damage." style="height: 15px;"></center>`);
            return;
          }

          const updateCharecterAllStar = async () => {
            $(row).find("td:nth-child(7)").html(characterAllStar(rank, outOf, dps, rankOnes[jobName][playerMetric]).toFixed(2));
          };

          if (!rankOnes[jobName]) {
            rankOnes[jobName] = {};
          }

          if (!rankOnes[jobName][playerMetric]) {
            const url = `https://www.fflogs.com/v1/rankings/encounter/${reportsCache.filterFightBoss}?metric=${playerMetric}&spec=${
              jobs.find((job) => job.name.replace(" ", "") === jobName)?.id
            }&api_key=${GM_getValue("apiKey")}`;
            fetch(url)
              .then((res) => res.json())
              .then((data) => {
                rankOnes[jobName][playerMetric] = Number(data.rankings[0].total.toFixed(1));
                updateCharecterAllStar();
              })
              .catch((err) => console.error(err));
          } else {
            updateCharecterAllStar();
          }
        };

        $(".player-table").each((_i, table) => {
          $(table)
            .find("thead tr th:nth-child(6)")
            .after(
              `<th class="sorting ui-state-default" tabindex="0" aria-controls="DataTables_Table_0" rowspan="1" colspan="1" aria-label="Patch: activate to sort column ascending"><div class="DataTables_sort_wrapper">Points<span class="DataTables_sort_icon css_right ui-icon ui-icon-caret-2-n-s"></span></div></th>`
            );
          $(table)
            .find("tbody tr")
            .each((_i, row) => {
              $(row)
                .find("td:nth-child(6)")
                .after(`<td class="rank-per-second primary main-table-number"><center><span class="zmdi zmdi-spinner zmdi-hc-spin" style="color:white font-size:24px"></center></span></td>`);
              rows.push(row);
            });
        });
      }

      // Events Tab
      if (hashParams.view === "events") {
        $(".events-table")
          .find("thead tr th:nth-child(1)")
          .before(`<th class="ui-state-default sorting_disabled" rowspan="1" colspan="1"><div class="DataTables_sort_wrapper">Diff<span class="DataTables_sort_icon"></span></div></th>`);

        $(".main-table-number").each((_i, cell) => {
          if (lastTimeDiff) {
            const time = moment($(cell).text(), "m:ss.SSS");
            const diff = (time.diff(lastTimeDiff) / 1000).toFixed(3);
            $(cell).before(`<td style="width: 2em; text-align: right;">${diff.padStart(5, "0")}</td>`);
            lastTimeDiff = time;
          } else {
            $(cell).before(`<td style="width: 2em; text-align: right;"> - </td>`);
            lastTimeDiff = moment($(cell).text(), "m:ss.SSS");
          }
        });
      }

      // LB Tab
      if (hashParams.view === "events" && hashParams.type === "summary" && hashParams.pins === LB_PIN) {
        $(".filter-type-tab.selected").removeClass("selected");
        $("#filter-lb-tab").addClass("selected");

        $(".events-table")
          .find("thead tr th:nth-last-child(3)")
          .after(`<th class="ui-state-default sorting_disabled" rowspan="1" colspan="1"><div class="DataTables_sort_wrapper">Active<span class="DataTables_sort_icon"></span></div></th>`);
        $(".events-table")
          .find("thead tr th:nth-last-child(2)")
          .after(`<th class="ui-state-default sorting_disabled" rowspan="1" colspan="1"><div class="DataTables_sort_wrapper">Bars<span class="DataTables_sort_icon"></span></div></th>`);

        $(".event-description-cell").each((_i, cell) => {
          const text = $(cell).text();
          if (text === "Event") {
            $(cell).html(`<div class="DataTables_sort_wrapper">Limit Break Total<span class="DataTables_sort_icon"></span></div>`);
            return;
          }

          if (!LB_REGEX.test(text)) {
            const time = moment($(cell).prev().text(), "mm:ss.SSS");

            if (time.diff(lastLbGainTime) > 0) {
              $(cell).prev().remove();
              $(cell).prev().remove();
              $(cell).next().remove();
              $(cell).remove();
            }
            $(cell).before(`<td style="width: 2em; text-align: right; white-space: nowrap;"> * </td>`);
            $(cell).after(`<td style="width: 2em; text-align: right;"> * </td>`);
            return;
          }

          const lb = text.match(LB_REGEX);
          const currentLb = Number(lb?.[1]);
          const currentBars = Number(lb?.[2]);
          const time = moment($(cell).prev().text(), "mm:ss.SSS");

          if (lb) {
            let diff;
            if (lastLbGain !== undefined) {
              diff = (currentLb - lastLbGain).toLocaleString();
            } else {
              diff = " - ";
            }
            lastLbGain = currentLb;
            let actualDiff = diff > 0 ? `+${diff}` : diff;

            if (PASSIVE_LB_GAIN[currentBars - 1].includes(diff)) {
              // passive lb gain
              diff = " - ";
            } else {
              // active lb gain
              if (time.diff(lastLbGainTime) > 0) {
                $(cell).attr("style", "border-top: 2px solid #ff0000;");
              }
              lastLbGainTime = time;
            }

            $(cell).before(`<td style="width: 2em; text-align: right; white-space: nowrap;">${diff}</td>`);
            $(cell).html(`${Number(currentLb).toLocaleString()} / ${(Number(currentBars) * 10000).toLocaleString()} <span style="float: right;">${actualDiff}</span>`);
            $(cell).after(`<td style="width: 2em; text-align: right;">${currentBars}</td>`);
          }
        });

        // fix diff
        setTimeout(() => {
          lastTimeDiff = undefined;
          $(".main-table-number").each((_i, cell) => {
            if (lastTimeDiff) {
              const time = moment($(cell).text(), "m:ss.SSS");
              const diff = (time.diff(lastTimeDiff) / 1000).toFixed(3);
              $(cell)
                .prev()
                .text(`${diff.padStart(5, "0")}`);
              lastTimeDiff = time;
            } else {
              $(cell).prev().text(` - `);
              lastTimeDiff = moment($(cell).text(), "m:ss.SSS");
            }
          });
        }, 1000);
      } else {
        $("#filter-lb-tab").removeClass("selected");
        $(`#filter-${hashParams.type}-tab`).addClass("selected");
        if (hashParams.pins === LB_PIN) {
          changeHashParams({ pins: "" });
        }
      }
    };

    const tableContainer = document.querySelector("#table-container");
    if (tableContainer) {
      const observer = new MutationObserver(onTableChange);
      observer.observe(tableContainer, { attributes: true, characterData: true, childList: true });
    }
  }

  // Zone Rankings Page
  if (ZONE_RANKINGS_PATH_REGEX.test(location.pathname)) {
    const onTableChange = () => {
      $(".main-table-name").each((_i, cell) => {
        if ($(cell).find(".main-table-realm").text().includes("(JP)")) {
          if ($(cell).find(".main-table-guild").attr("href").includes("translate=true")) return;
          $(cell)
            .find(".main-table-guild")
            .attr("href", `${$(cell).find(".main-table-guild").attr("href")}&translate=true`);
        }
      });
    };

    onTableChange();
    const tableContainer = document.querySelector("#table-container");
    if (tableContainer) {
      const observer = new MutationObserver(onTableChange);
      observer.observe(tableContainer, { attributes: true, characterData: true, childList: true });
    }
  }

  // Character Page
  if (CHARACTER_PATH_REGEX.test(location.pathname)) {
    // Chad Bradly's Profile Customization
    const CHAD_ID_REGEX = /\/character\/id\/12781922/;
    const CHAD_NAME_REGEX = /\/character\/na\/sargatanas\/chad%20bradly/;
    const CHAD_ICON_URL = "https://media.tenor.com/epNMHGvRyHcAAAAd/gigachad-chad.gif";

    if (CHAD_ID_REGEX.test(location.pathname) || CHAD_NAME_REGEX.test(location.pathname)) {
      $("#character-portrait-image").attr("src", CHAD_ICON_URL);
    }
  }

  // Profile Page
  if (PROFILE_PATH_REGEX.test(location.pathname)) {
    const $extension = $(`
      <div id="extension" class="dialog-block">
        <div id="extension-title" class="dialog-title">FF Progs</div>
        <div id="extension-content" style="margin:1em"></div>
      </div>
    `);

    const $apiInputContainer = $(`
      <div id="api-input-container" style="margin:1em">
        <div>Enter your FFLogs API Key</div>
        <input type="text" id="api-key-input" style="margin-left: 10px" value="${apiKey || ""}">
        <input type="button" id="api-save-button" style="margin-left: 10px" value="${apiKey ? "Update API Key" : "Save API Key"}">
      </div>
    `);

    const $apiStatus = $(`
      <div id="api-status" style="margin:1em; display: ${apiKey ? "block" : "none"}">
        <div>API Key ${apiKey ? "saved" : "not saved"}</div>
        <input type="button" id="api-remove-button" style="margin-left: 10px" value="Remove API Key">
      </div>
    `);

    const saveApiKey = () => {
      const newApiKey = $("#api-key-input").val().trim();
      if (newApiKey) {
        GM_setValue("apiKey", newApiKey);
        $apiStatus.show().find("div").text("API Key saved");
        $apiInputContainer.hide();
        setTimeout(() => {
          $apiStatus.hide();
          $apiInputContainer.show();
        }, 2000);
      }
    };

    const removeApiKey = () => {
      GM_deleteValue("apiKey");
      $apiStatus.show().find("div").text("API Key removed");
      $apiStatus.find("#api-remove-button").remove();
      $apiInputContainer.show();
      setTimeout(() => {
        $apiStatus.hide();
      }, 2000);
    };

    $extension.insertAfter("#api");
    $apiInputContainer.appendTo("#extension-content");
    $apiStatus.appendTo("#extension-content");

    $apiInputContainer.on("click", "#api-save-button", saveApiKey);
    $apiStatus.on("click", "#api-remove-button", removeApiKey);
  }
})();