FF Progs

Improves FFlogs.

Από την 22/03/2025. Δείτε την τελευταία έκδοση.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name                FF Progs
// @name:en             FF Progs
// @description         Improves FFlogs.
// @description:en      Improves FFlogs.
// @version             1.4.2
// @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/*
// @match               https://*.warcraftlogs.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";

  // If you want to add more filter expression presets, you can add them here.
  const PIN_PRESETS = {
    "No Filter": "",
    calulateddamage: "type='calculateddamage'",
    damage: "type='damage'",
    cast: "type='cast'",
    Targetability: "type='targetabilityupdate'",
    GCD: "ability.isOffGCD=false",
    "Kill Event": "kill",
    LB: `2$Main$#ffff14$script$let l;pinMatchesFightEvent=(e,f)=>{switch(e.type){case"limitbreakupdate":return l&&l===e.timestamp||(l=e.timestamp),!0;case"calculateddamage":if("Player"===e.target.type&&e.timestamp===l)return!0;break;case"heal":if(!e.isTick&&e.timestamp===l)return!0}return!1};`,
    Magical: "ability.type=1024",
    Physical: "ability.type=128",
    Heal: "ability.type=8",
    DOT: "ability.type=64",
    "Buff/Debuff": "ability.type=1",
    Darkness: "ability.type=124",
    True: "ability.type=32",
    System: "ability.type=0",
  };

  const ABILITY_TYPES = {
    0: "System",
    1: "Buff/Debuff",
    // 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 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 getQueryParams = () => {
    const queryParams = new URLSearchParams(window.location.search);
    const params = Object.fromEntries(queryParams.entries());
    return params;
  };

  const changeQueryParams = (defaultParams) => {
    const url = new URL(window.location);

    const queryParams = new URLSearchParams(url.search);

    Object.entries(defaultParams).forEach(([key, value]) => {
      if (value !== null && value !== undefined && value !== "") {
        queryParams.set(key, value);
      } else {
        queryParams.delete(key);
      }
    });

    changeView(queryParams);
  };

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

  function parseTime(cell) {
    const [_, sign, m, s, ms] = $(cell)
      .text()
      .match(/(-?)(\d+):(\d+)\.(\d+)/);
    const totalMs = (+m * 60000 + +s * 1000 + +ms) * (sign === "-" ? -1 : 1);
    return totalMs;
  }

  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, #gear-box-ad, .ad-placement-sticky-footer, .content-sidebar, #ap-ea8a4fe5-container, #tile-content-ap, #builds-banner"
  ).remove();
  $(".content-with-sidebar").css("display", "block");
  $("#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").on("click", () => {
      window.open(`https://xivanalysis.com/report-redirect/${location.href}`, "_blank");
    });

    if (!$("#filter-rankings-tab").length) {
      $("#filter-replay-tab").before(
        `<a href="#" class="big-tab view-type-tab" id="filter-rankings-tab" onclick="return changeFilterView('rankings', this)" oncontextmenu="changeFilterView('rankings', this)"><span class="zmdi zmdi-sort"></span><span class="big-tab-text"><br>Rankings</span></a>`
      );
    }

    // Fixes Cursor behavior on Filter Type Tabs
    $("#filter-type-tabs").css("cursor", "default");

    const rankOnes = {};
    let jobs;

    const onTableChange = () => {
      let queryParams = getQueryParams();
      let lastLbGain;
      let lastTimeDiff;
      let lastSkillName;
      let lastSelectedPreset;

      // Filter Presets
      if (!queryParams.view || queryParams.view === "events" || queryParams.view === "timeline" || queryParams.view === "execution") {
        if (!$("#presets").length) {
          $("#graph-title-strip > div:first-child").after(
            `<div style="margin-left: auto; padding-right: 8px;" id="presets">
              No Overwrite:
              <input type="checkbox" id="no-overwrite" style="margin-right: 8px;">
              Filter Presets:
                <select id="presets-select" style="margin-right: 8px;">
                  ${Object.entries(PIN_PRESETS)
                    .map(([name, pin]) => `<option value="${pin}">${name}</option>`)
                    .join("")}
                </select>
              </div>`
          );

          $("#presets-select").on("change", (e) => {
            const selected = $("#presets-select").val();
            const name = $("#presets-select option:selected").text();

            if (!selected) {
              changeQueryParams({
                type: "",
                view: "",
                source: "",
                hostility: "",
                pins: "",
                start: "",
                end: "",
              });
              lastSelectedPreset = null;
              return;
            }
            if (name === "LB") {
              changeQueryParams({
                type: "summary",
                view: "events",
                source: "",
                pins: PIN_PRESETS.LB,
              });
              lastSelectedPreset = name
              return;
            }
            if (name === "Kill Event") {
              changeQueryParams({
                type: "resources",
                view: "events",
                source: "",
                hostility: "1",
                pins: "",
                start: fightSegmentEndTime - 5 * 1000,
                end: fightSegmentEndTime,
              });
              lastSelectedPreset = name
              return;
            }
            const pinTemplate = `2$Off$#244F4B$expression$${selected}`;
            if ($("#no-overwrite").is(":checked") && lastSelectedPreset !== "LB" && lastSelectedPreset !== "Kill Event") {
              queryParams = getQueryParams();
              changeQueryParams({ pins: queryParams.pins ? `${queryParams.pins}^${pinTemplate}` : pinTemplate });
            } else {
              changeQueryParams({ pins: pinTemplate });
            }
            lastSelectedPreset = name
          });
        }
      }

      // Rankings Tab
      if (queryParams.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 queryParams = getQueryParams();
          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 = queryParams.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 (queryParams.view === "events") {
        if (queryParams.type === "resources") {
          return;
        }
        $(".events-table")
          .find("thead tr th:nth-child(1)")
          .after(`<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 !== undefined) {
            const time = parseTime(cell);
            const diff = ((time - lastTimeDiff) / 1000).toFixed(3);
            const whiteListlastSkill = ["Ten", "Chi", "Jin", "Savage Claw"];
            const whiteListSkill = ["Quadruple Technical Finish", "Pneuma", "Star Prism"];
            let bgColor = "";
            const skillName = $(cell).next().next().find("a").text();
            if (queryParams.type === "casts" && !!queryParams.source && !whiteListlastSkill.includes(lastSkillName) && !!lastSkillName && !whiteListSkill.includes(skillName) && !!skillName) {
              if (diff < 0.576 && diff > 0.2) {
                bgColor = "background-color: chocolate !important;";
              }
              if (diff > 5) {
                bgColor = "background-color: gray !important;";
              }
            }
            lastSkillName = skillName;
            $(cell).after(`<td style="width: 2em; text-align: right; ${bgColor}">${diff}</td>`);
            lastTimeDiff = time;
          } else {
            $(cell).after(`<td style="width: 2em; text-align: right;"> - </td>`);
            lastTimeDiff = parseTime(cell);
          }
        });

        if (queryParams.type === "casts" && queryParams.hostility === "1") {
          $(".event-ability-cell a").each((_i, cell) => {
            const actionId = $(cell).attr("href").split("/")[5];
            const hexId = parseInt(actionId).toString(16);
            $(cell).text(`${$(cell).text()} [${hexId}]`);
          });
        }
      }

      // LB Pin
      if (queryParams.view === "events" && queryParams.type === "summary" && queryParams.pins === PIN_PRESETS.LB) {
        $(".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)) {
            $(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]);

          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
            }

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

    updateTable(onTableChange);
  }

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

    onTableChange();
    updateTable(onTableChange);
  }

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