FF Scouter xentac

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

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

// ==UserScript==
// @name          FF Scouter xentac
// @namespace     Violentmonkey Scripts
// @match         https://www.torn.com/*
// @version       2.3.1
// @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.3.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.fair_fight) {
      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].fair_fight;
        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 && ff.fair_fight) {
        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.fair_fight) {
        // 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,
  });
}