FF Scouter xentac

Shows the expected Fair Fight score against targets. Modified to work with new ffscouter.com.

目前為 2025-05-16 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name          FF Scouter xentac
// @namespace     Violentmonkey Scripts
// @match         https://www.torn.com/*
// @version       2.2
// @author        rDacted, xentac
// @description   Shows the expected Fair Fight score against targets. Modified to work with new ffscouter.com.
// @grant         GM_xmlhttpRequest
// @grant         GM_setValue
// @grant         GM_getValue
// @grant         GM_deleteValue
// @grant         GM_registerMenuCommand
// @grant         GM_addStyle
// @connect       ffscouter.com
// ==/UserScript==

const FF_VERSION = 2.1;

// This is a standalone version of FF Scouter which has been integrated into TornTools
// This version is provided for TornPDA users, or those that don't use TornTools
// However I (rDacted) have quit torn, so this script is provided in an unsupported manner
// I encourage anyone to re-implement this script if they're willing to provide support to the community

// Ensure this code can only ever run once in any page
let singleton = document.getElementById("ff-scouter-run-once");
if (!singleton) {
  console.log(`FF Scouter version ${FF_VERSION} starting`);
  GM_addStyle(`
        .ff-scouter-indicator {
        position: relative;
        display: block;
        padding: 0;
        }

        .ff-scouter-vertical-line-low-upper,
        .ff-scouter-vertical-line-low-lower,
        .ff-scouter-vertical-line-high-upper,
        .ff-scouter-vertical-line-high-lower {
        content: '';
        position: absolute;
        width: 2px;
        height: 30%;
        background-color: black;
        margin-left: -1px;
        }

        .ff-scouter-vertical-line-low-upper {
        top: 0;
        left: calc(var(--arrow-width) / 2 + 33 * (100% - var(--arrow-width)) / 100);
        }

        .ff-scouter-vertical-line-low-lower {
        bottom: 0;
        left: calc(var(--arrow-width) / 2 + 33 * (100% - var(--arrow-width)) / 100);
        }

        .ff-scouter-vertical-line-high-upper {
        top: 0;
        left: calc(var(--arrow-width) / 2 + 66 * (100% - var(--arrow-width)) / 100);
    }

        .ff-scouter-vertical-line-high-lower {
        bottom: 0;
        left: calc(var(--arrow-width) / 2 + 66 * (100% - var(--arrow-width)) / 100);
        }

        .ff-scouter-arrow {
        position: absolute;
        transform: translate(-50%, -50%);
        padding: 0;
        top: 0;
        left: calc(var(--arrow-width) / 2 + var(--band-percent) * (100% - var(--arrow-width)) / 100);
        /*height: 40%;*/
        width: var(--arrow-width);
        object-fit: cover;
        pointer-events: none; /* Allow clicks to pass through */
        }
    `);

  var BASE_URL = "https://ffscouter.com";
  var BLUE_ARROW =
    "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/blue-arrow.svg";
  var GREEN_ARROW =
    "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/green-arrow.svg";
  var RED_ARROW =
    "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/red-arrow.svg";

  var rD_xmlhttpRequest;
  var rD_setValue;
  var rD_getValue;
  var rD_deleteValue;
  var rD_registerMenuCommand;

  // DO NOT CHANGE THIS
  // DO NOT CHANGE THIS
  var apikey = "###PDA-APIKEY###";
  // DO NOT CHANGE THIS
  // DO NOT CHANGE THIS
  if (apikey[0] != "#") {
    console.log("Adding modifications to support TornPDA");
    rD_xmlhttpRequest = function (details) {
      console.log("Attempt to make http request");
      if (details.method.toLowerCase() == "get") {
        return PDA_httpGet(details.url)
          .then(details.onload)
          .catch(details.onerror ?? ((e) => console.error(e)));
      } else if (details.method.toLowerCase() == "post") {
        return PDA_httpPost(
          details.url,
          details.headers ?? {},
          details.body ?? details.data ?? "",
        )
          .then(details.onload)
          .catch(details.onerror ?? ((e) => console.error(e)));
      } else {
        console.log("What is this? " + details.method);
      }
    };
    rD_setValue = function (name, value) {
      console.log("Attempted to set " + name);
      return localStorage.setItem(name, value);
    };
    rD_getValue = function (name, defaultValue) {
      var value = localStorage.getItem(name) ?? defaultValue;
      //console.log("Attempted to get " + name + " -> " + value);
      return value;
    };
    rD_deleteValue = function (name) {
      console.log("Attempted to delete " + name);
      return localStorage.removeItem(name);
    };
    rD_registerMenuCommand = function () {
      console.log("Disabling GM_registerMenuCommand");
    };
    rD_setValue("limited_key", apikey);
  } else {
    rD_xmlhttpRequest = GM_xmlhttpRequest;
    rD_setValue = GM_setValue;
    rD_getValue = GM_getValue;
    rD_deleteValue = GM_deleteValue;
    rD_registerMenuCommand = GM_registerMenuCommand;
  }

  var key = rD_getValue("limited_key", null);
  var info_line = null;

  rD_registerMenuCommand("Enter Limited API Key", () => {
    let userInput = prompt(
      "Enter Limited API Key",
      rD_getValue("limited_key", ""),
    );
    if (userInput !== null) {
      rD_setValue("limited_key", userInput);
      // Reload page
      window.location.reload();
    }
  });

  function create_text_location() {
    info_line = document.createElement("div");
    info_line.id = "ff-scouter-run-once";
    info_line.style.display = "flex"; // Use flexbox for centering
    info_line.style.cursor = "pointer"; // Change cursor to pointer
    info_line.addEventListener("click", () => {
      if (key === null) {
        const limited_key = prompt(
          "Enter Limited API Key",
          rD_getValue("limited_key", ""),
        );
        if (limited_key) {
          // Store the API key with rD_setValue
          rD_setValue("limited_key", limited_key);
          key = limited_key;
          // Reload page
          window.location.reload();
        }
      }
    });

    var h4 = $("h4")[0];
    if (h4.textContent === "Attacking") {
      h4.parentNode.parentNode.after(info_line);
    } else {
      h4.after(info_line);
    }

    return info_line;
  }

  function set_message(message, error = false) {
    while (info_line.firstChild) {
      info_line.removeChild(info_line.firstChild);
    }

    const textNode = document.createTextNode(message);
    if (error) {
      info_line.style.color = "red";
    } else {
      info_line.style.color = "";
    }
    info_line.appendChild(textNode);
  }

  function update_ff_cache(player_ids, callback) {
    if (!key) {
      return;
    }

    // Deduplicate
    player_ids = [...new Set(player_ids)];

    // Given a list of players remove any where the cache is already fresh enough
    // Then make a request for any unknown players and call the callback
    var unknown_player_ids = get_cache_misses(player_ids);

    if (unknown_player_ids.length > 0) {
      console.log(`Refreshing cache for ${unknown_player_ids.length} ids`);

      var player_id_list = unknown_player_ids.join(",");
      const url = `${BASE_URL}/api/v1/get-stats?key=${key}&targets=${player_id_list}`;

      //console.log(url);

      rD_xmlhttpRequest({
        method: "GET",
        url: url,
        onload: function (response) {
          if (response.status == 200) {
            var ff_response = JSON.parse(response.responseText);
            //console.log(ff_response);
            if (!("error" in ff_response)) {
              var one_hour = 60 * 60 * 1000;
              var expiry = Date.now() + one_hour;

              ff_response.forEach((result) => {
                console.log(result);
                if (result.player_id) {
                  id = result.player_id;
                  // Cache the value
                  //console.log("Caching stats for " + id);
                  result.expiry = expiry;
                  rD_setValue("" + id, JSON.stringify(result));
                }
              });

              callback(player_ids);
            } else {
              console.log(
                "FF Scouter failed to get player information. Error message: " +
                  ff_response.error,
              );
            }
          } else {
            console.log(
              "Failed to make request, status code " + response.status,
            );
          }
        },
        onerror: function (e) {
          console.error("**** error ", e);
        },
        onabort: function (e) {
          console.error("**** abort ", e);
        },
        ontimeout: function (e) {
          console.error("**** timeout ", e);
        },
      });
    } else {
      callback(player_ids);
    }
  }

  function get_fair_fight_response(target_id) {
    var cached_ff_response = rD_getValue("" + target_id, null);
    try {
      cached_ff_response = JSON.parse(cached_ff_response);
    } catch {
      cached_ff_response = null;
    }

    if (cached_ff_response) {
      if (cached_ff_response.expiry > Date.now()) {
        return cached_ff_response;
      }
    }
  }

  function display_fair_fight(target_id) {
    const response = get_fair_fight_response(target_id);
    if (response) {
      set_fair_fight(response);
    }
  }

  function get_ff_string(ff_response) {
    const ff = ff_response.fair_fight.toFixed(2);

    const now = Date.now() / 1000;
    const age = now - ff_response.last_updated;

    var suffix = "";
    if (age > 14 * 24 * 60 * 60) {
      suffix = "?";
    }

    return `${ff}${suffix}`;
  }

  function get_ff_string_short(ff_response) {
    const ff = ff_response.fair_fight.toFixed(2);

    const now = Date.now() / 1000;
    const age = now - ff_response.last_updated;

    if (ff > 9) {
      return "high";
    }

    var suffix = "";
    if (age > 14 * 24 * 60 * 60) {
      suffix = "?";
    }

    return `${ff}${suffix}`;
  }

  function get_detailed_message(ff_response) {
    const ff_string = get_ff_string(ff_response);

    const now = Date.now() / 1000;
    const age = now - ff_response.last_updated;

    var fresh = "";

    if (age < 24 * 60 * 60) {
      // Pass
    } else if (age < 31 * 24 * 60 * 60) {
      var days = Math.round(age / (24 * 60 * 60));
      if (days == 1) {
        fresh = "(1 day old)";
      } else {
        fresh = `(${days} days old)`;
      }
    } else if (age < 365 * 24 * 60 * 60) {
      var months = Math.round(age / (31 * 24 * 60 * 60));
      if (months == 1) {
        fresh = "(1 month old)";
      } else {
        fresh = `(${months} months old)`;
      }
    } else {
      var years = Math.round(age / (365 * 24 * 60 * 60));
      if (years == 1) {
        fresh = "(1 year old)";
      } else {
        fresh = `(${years} years old)`;
      }
    }

    return `Fair Fight ${ff_string} ${fresh}`;
  }

  function set_fair_fight(ff_response) {
    const detailed_message = get_detailed_message(ff_response);
    set_message(detailed_message);
  }

  function get_members() {
    var player_ids = [];
    $(".table-body > .table-row").each(function () {
      if (!$(this).find(".fallen").length) {
        if (!$(this).find(".fedded").length) {
          $(this)
            .find(".member")
            .each(function (index, value) {
              var url = value.querySelectorAll('a[href^="/profiles"]')[0].href;
              var player_id = url.match(/.*XID=(?<player_id>\d+)/).groups
                .player_id;
              player_ids.push(parseInt(player_id));
            });
        }
      }
    });

    return player_ids;
  }

  function rgbToHex(r, g, b) {
    return (
      "#" +
      ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()
    ); // Convert to hex and return
  }

  function get_ff_colour(value) {
    let r, g, b;

    // Transition from
    // blue - #2828c6
    // to
    // green - #28c628
    // to
    // red - #c62828
    if (value <= 1) {
      // Blue
      r = 0x28;
      g = 0x28;
      b = 0xc6;
    } else if (value <= 3) {
      // Transition from blue to green
      const t = (value - 1) / 2; // Normalize to range [0, 1]
      r = 0x28;
      g = Math.round(0x28 + (0xc6 - 0x28) * t);
      b = Math.round(0xc6 - (0xc6 - 0x28) * t);
    } else if (value <= 5) {
      // Transition from green to red
      const t = (value - 3) / 2; // Normalize to range [0, 1]
      r = Math.round(0x28 + (0xc6 - 0x28) * t);
      g = Math.round(0xc6 - (0xc6 - 0x28) * t);
      b = 0x28;
    } else {
      // Red
      r = 0xc6;
      g = 0x28;
      b = 0x28;
    }

    return rgbToHex(r, g, b); // Return hex value
  }

  function get_contrast_color(hex) {
    // Convert hex to RGB
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);

    // Calculate brightness
    const brightness = r * 0.299 + g * 0.587 + b * 0.114;
    return brightness > 126 ? "black" : "white"; // Return black or white based on brightness
  }

  function apply_fair_fight_info(player_ids) {
    const fair_fights = new Object();

    for (const player_id of player_ids) {
      var cached_ff_response = rD_getValue("" + player_id, null);
      try {
        cached_ff_response = JSON.parse(cached_ff_response);
      } catch {
        cached_ff_response = null;
      }

      if (cached_ff_response) {
        if (cached_ff_response.expiry > Date.now()) {
          fair_fights[player_id] = cached_ff_response;
        }
      }
    }

    var header_li = document.createElement("li");
    header_li.tabIndex = "0";
    header_li.classList.add("table-cell");
    header_li.classList.add("lvl");
    header_li.classList.add("torn-divider");
    header_li.classList.add("divider-vertical");
    header_li.classList.add("c-pointer");
    header_li.appendChild(document.createTextNode("FF"));

    $(".table-header > .lvl")[0].after(header_li);

    $(".table-body > .table-row > .member").each(function (index, value) {
      var url = value.querySelectorAll('a[href^="/profiles"]')[0].href;
      var player_id = url.match(/.*XID=(?<player_id>\d+)/).groups.player_id;

      var fair_fight_div = document.createElement("div");
      fair_fight_div.classList.add("table-cell");
      fair_fight_div.classList.add("lvl");

      // Lookup the fair fight score from cache
      if (fair_fights[player_id]) {
        const ff = fair_fights[player_id].value;
        const ff_string = get_ff_string_short(fair_fights[player_id]);

        const background_colour = get_ff_colour(ff);
        const text_colour = get_contrast_color(background_colour);
        fair_fight_div.style.backgroundColor = background_colour;
        fair_fight_div.style.color = text_colour;
        fair_fight_div.style.fontWeight = "bold";
        var text = document.createTextNode(ff_string);
        fair_fight_div.appendChild(text);
      }

      value.nextSibling.after(fair_fight_div);
    });
  }

  function get_cache_misses(player_ids) {
    var unknown_player_ids = [];
    for (const player_id of player_ids) {
      var cached_ff_response = rD_getValue("" + player_id, null);
      try {
        cached_ff_response = JSON.parse(cached_ff_response);
      } catch {
        cached_ff_response = null;
      }

      if (
        !cached_ff_response ||
        cached_ff_response.expiry < Date.now() ||
        cached_ff_response.age > 7 * 24 * 60 * 60
      ) {
        unknown_player_ids.push(player_id);
      }
    }

    return unknown_player_ids;
  }

  create_text_location();

  const match1 = window.location.href.match(
    /https:\/\/www.torn.com\/profiles.php\?XID=(?<target_id>\d+)/,
  );
  const match2 = window.location.href.match(
    /https:\/\/www.torn.com\/loader.php\?sid=attack&user2ID=(?<target_id>\d+)/,
  );
  const match = match1 ?? match2;
  if (match) {
    // We're on a profile page or an attack page - get the fair fight score
    var target_id = match.groups.target_id;
    update_ff_cache([target_id], function (target_ids) {
      display_fair_fight(target_ids[0]);
    });

    if (!key) {
      set_message("Limited API key needed - click to add");
    }
  } else if (
    window.location.href.startsWith("https://www.torn.com/factions.php")
  ) {
    const torn_observer = new MutationObserver(function () {
      // Find the member table - add a column if it doesn't already have one, for FF scores
      var members_list = $(".members-list")[0];
      if (members_list) {
        torn_observer.disconnect();

        var player_ids = get_members();
        update_ff_cache(player_ids, apply_fair_fight_info);
      }
    });

    torn_observer.observe(document, {
      attributes: false,
      childList: true,
      characterData: false,
      subtree: true,
    });

    if (!key) {
      set_message("Limited API key needed - click to add");
    }
  } else {
    // console.log("Did not match against " + window.location.href);
  }

  function get_player_id_in_element(element) {
    const match = element.parentElement?.href?.match(/.*XID=(?<target_id>\d+)/);
    if (match) {
      return match.groups.target_id;
    }

    const anchors = element.getElementsByTagName("a");

    for (const anchor of anchors) {
      const match = anchor.href.match(/.*XID=(?<target_id>\d+)/);
      if (match) {
        return match.groups.target_id;
      }
    }

    if (element.nodeName.toLowerCase() === "a") {
      const match = element.href.match(/.*XID=(?<target_id>\d+)/);
      if (match) {
        return match.groups.target_id;
      }
    }

    return null;
  }

  function get_ff(target_id) {
    const response = get_fair_fight_response(target_id);
    if (response) {
      return response.fair_fight;
    }

    return null;
  }

  function ff_to_percent(ff) {
    // There are 3 key areas, low, medium, high
    // Low is 1-2
    // Medium is 2-4
    // High is 4+
    // If we clip high at 8 then the math becomes easy
    // The percent is 0-33% 33-66% 66%-100%
    const low_ff = 2;
    const high_ff = 4;
    const low_mid_percent = 33;
    const mid_high_percent = 66;
    ff = Math.min(ff, 8);
    var percent;
    if (ff < low_ff) {
      percent = ((ff - 1) / (low_ff - 1)) * low_mid_percent;
    } else if (ff < high_ff) {
      percent =
        ((ff - low_ff) / (high_ff - low_ff)) *
          (mid_high_percent - low_mid_percent) +
        low_mid_percent;
    } else {
      percent =
        ((ff - high_ff) / (8 - high_ff)) * (100 - mid_high_percent) +
        mid_high_percent;
    }

    return percent;
  }

  function show_cached_values(elements) {
    for (const [player_id, element] of elements) {
      element.classList.add("ff-scouter-indicator");
      if (!element.classList.contains("indicator-lines")) {
        element.classList.add("indicator-lines");
        element.style.setProperty("--arrow-width", "20px");

        // Ugly - does removing this break anything?
        element.classList.remove("small");
        element.classList.remove("big");

        //$(element).append($("<div>", { class: "ff-scouter-vertical-line-low-upper" }));
        //$(element).append($("<div>", { class: "ff-scouter-vertical-line-low-lower" }));
        //$(element).append($("<div>", { class: "ff-scouter-vertical-line-high-upper" }));
        //$(element).append($("<div>", { class: "ff-scouter-vertical-line-high-lower" }));
      }

      const ff = get_ff(player_id);
      if (ff) {
        const percent = ff_to_percent(ff);
        element.style.setProperty("--band-percent", percent);

        $(element).find(".ff-scouter-arrow").remove();

        var arrow;
        if (percent < 33) {
          arrow = BLUE_ARROW;
        } else if (percent < 66) {
          arrow = GREEN_ARROW;
        } else {
          arrow = RED_ARROW;
        }
        const img = $("<img>", {
          src: arrow,
          class: "ff-scouter-arrow",
        });
        $(element).append(img);
      }
    }
  }

  async function apply_ff_gauge(elements) {
    // Remove elements which already have the class
    elements = elements.filter(
      (e) => !e.classList.contains("ff-scouter-indicator"),
    );
    // Convert elements to a list of tuples
    elements = elements.map((e) => {
      const player_id = get_player_id_in_element(e);
      return [player_id, e];
    });
    // Remove any elements that don't have an id
    elements = elements.filter((e) => e[0]);

    if (elements.length > 0) {
      // Display cached values immediately
      // This is also important to ensure we only iterate the list once
      // Then update
      // Then re-display after the update
      show_cached_values(elements);
      const player_ids = elements.map((e) => e[0]);
      update_ff_cache(player_ids, () => {
        show_cached_values(elements);
      });
    }
  }

  async function apply_to_mini_profile(mini) {
    // Get the user id, and the details
    // Then in profile-container.description append a new span with the text. Win
    const player_id = get_player_id_in_element(mini);
    if (player_id) {
      const response = get_fair_fight_response(player_id);
      if (response) {
        // Remove any existing elements
        $(mini).find(".ff-scouter-mini-ff").remove();

        const message = get_detailed_message(response);

        const description = $(mini).find(".description");
        const desc = $("<span></span>", {
          class: "ff-scouter-mini-ff",
        });
        desc.text(message);
        $(description).append(desc);
      }
    }
  }

  const ff_gauge_observer = new MutationObserver(async function () {
    var honor_bars = $(".honor-text-wrap").toArray();
    if (honor_bars.length > 0) {
      await apply_ff_gauge($(".honor-text-wrap").toArray());
    } else {
      if (
        window.location.href.startsWith("https://www.torn.com/factions.php")
      ) {
        await apply_ff_gauge($(".member").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/companies.php")
      ) {
        await apply_ff_gauge($(".employee").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/joblist.php")
      ) {
        await apply_ff_gauge($(".employee").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/messages.php")
      ) {
        await apply_ff_gauge($(".name").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/index.php")
      ) {
        await apply_ff_gauge($(".name").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/hospitalview.php")
      ) {
        await apply_ff_gauge($(".name").toArray());
      } else if (
        window.location.href.startsWith(
          "https://www.torn.com/page.php?sid=UserList",
        )
      ) {
        await apply_ff_gauge($(".name").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/bounties.php")
      ) {
        await apply_ff_gauge($(".target").toArray());
        await apply_ff_gauge($(".listed").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/forums.php")
      ) {
        await apply_ff_gauge($(".last-poster").toArray());
        await apply_ff_gauge($(".starter").toArray());
        await apply_ff_gauge($(".last-post").toArray());
        await apply_ff_gauge($(".poster").toArray());
      } else if (
        window.location.href.startsWith("https://www.torn.com/page.php?sid=hof")
      ) {
        await apply_ff_gauge($('[class^="userInfoBox__"]').toArray());
      }
    }

    // Update any mini-profiles
    // Search for profile-mini-_userProfileWrapper___iIXVW
    var mini_profiles = $(
      '[class^="profile-mini-_userProfileWrapper_"]',
    ).toArray();
    if (mini_profiles.length > 0) {
      for (const mini of mini_profiles) {
        if (!mini.classList.contains("ff-processed")) {
          mini.classList.add("ff-processed");

          const player_id = get_player_id_in_element(mini);
          apply_to_mini_profile(mini);
          update_ff_cache([player_id], () => {
            apply_to_mini_profile(mini);
          });
        }
      }
    }
  });

  ff_gauge_observer.observe(document, {
    attributes: false,
    childList: true,
    characterData: false,
    subtree: true,
  });
}