FF Scouter V2

Shows the expected Fair Fight score against targets and faction war status

As of 2025-07-25. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(I already have a user script manager, let me install it!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         FF Scouter V2
// @namespace    Violentmonkey Scripts
// @match        https://www.torn.com/*
// @version      2.46
// @author       rDacted, Weav3r, xentac
// @description  Shows the expected Fair Fight score against targets and faction war status
// @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.46";
const API_INTERVAL = 30000;
const memberCountdowns = {};
let apiCallInProgressCount = 0;

let singleton = document.getElementById("ff-scouter-run-once");
if (!singleton) {
  console.log(`[FF Scouter V2] 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);
            width: var(--arrow-width);
            object-fit: cover;
            pointer-events: none;
            }
     
            .last-action-row {
                font-size: 11px;
                color: inherit;
                font-style: normal;
                font-weight: normal;
                text-align: center;
                margin-left: 8px;
                margin-bottom: 2px;
                margin-top: -2px;
                display: block;
            }
            .travel-status {
                display: flex;
                align-items: center;
                justify-content: flex-end;
                gap: 2px;
                min-width: 0;
                overflow: hidden;
            }
            .torn-symbol {
                width: 16px;
                height: 16px;
                fill: currentColor;
                vertical-align: middle;
                flex-shrink: 0;
            }
            .plane-svg {
                width: 14px;
                height: 14px;
                fill: currentColor;
                vertical-align: middle;
                flex-shrink: 0;
            }
            .plane-svg.returning {
                transform: scaleX(-1);
            }
            .country-abbr {
                overflow: hidden;
                text-overflow: ellipsis;
                white-space: nowrap;
                min-width: 0;
                flex: 0 1 auto;
                vertical-align: bottom;
            }
        `);

  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("[FF Scouter V2] Adding modifications to support TornPDA");
    rD_xmlhttpRequest = function (details) {
      console.log("[FF Scouter V2] Attempt to make http request");
      if (details.method.toLowerCase() == "get") {
        return PDA_httpGet(details.url)
          .then(details.onload)
          .catch(
            details.onerror ?? ((e) => console.error("[FF Scouter V2] ", 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("[FF Scouter V2] ", e)),
          );
      } else {
        console.log("[FF Scouter V2] What is this? " + details.method);
      }
    };
    rD_setValue = function (name, value) {
      console.log("[FF Scouter V2] Attempted to set " + name);
      return localStorage.setItem(name, value);
    };
    rD_getValue = function (name, defaultValue) {
      var value = localStorage.getItem(name) ?? defaultValue;
      return value;
    };
    rD_deleteValue = function (name) {
      console.log("[FF Scouter V2] Attempted to delete " + name);
      return localStorage.removeItem(name);
    };
    rD_registerMenuCommand = function () {
      console.log("[FF Scouter V2] 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 = "block";
    info_line.style.clear = "both";
    info_line.style.margin = "5px 0";
    info_line.addEventListener("click", () => {
      if (key === null) {
        const limited_key = prompt(
          "Enter Limited API Key",
          rD_getValue("limited_key", ""),
        );
        if (limited_key) {
          rD_setValue("limited_key", limited_key);
          key = limited_key;
          window.location.reload();
        }
      }
    });

    var h4 = $("h4")[0];
    if (h4.textContent === "Attacking") {
      h4.parentNode.parentNode.after(info_line);
    } else {
      const linksTopWrap = h4.parentNode.querySelector(".links-top-wrap");
      if (linksTopWrap) {
        linksTopWrap.parentNode.insertBefore(
          info_line,
          linksTopWrap.nextSibling,
        );
      } 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;
    }

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

    var unknown_player_ids = get_cache_misses(player_ids);

    if (unknown_player_ids.length > 0) {
      console.log(
        `[FF Scouter V2] 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}`;

      rD_xmlhttpRequest({
        method: "GET",
        url: url,
        onload: function (response) {
          if (response.status == 200) {
            var ff_response = JSON.parse(response.responseText);
            if (ff_response && ff_response.error) {
              showToast(ff_response.error);
              return;
            }
            var one_hour = 60 * 60 * 1000;
            var expiry = Date.now() + one_hour;
            ff_response.forEach((result) => {
              if (result && result.player_id) {
                if (result.fair_fight === null) {
                  let cacheObj = {
                    no_data: true,
                    expiry: expiry,
                  };
                  rD_setValue("" + result.player_id, JSON.stringify(cacheObj));
                } else {
                  let cacheObj = {
                    value: result.fair_fight,
                    last_updated: result.last_updated,
                    expiry: expiry,
                    bs_estimate: result.bs_estimate,
                    bs_estimate_human: result.bs_estimate_human,
                  };
                  rD_setValue("" + result.player_id, JSON.stringify(cacheObj));
                }
              }
            });
            callback(player_ids);
          } else {
            try {
              var err = JSON.parse(response.responseText);
              if (err && err.error) {
                showToast(err.error);
              } else {
                showToast("API request failed.");
              }
            } catch {
              showToast("API request failed.");
            }
          }
        },
        onerror: function (e) {
          console.error("[FF Scouter V2] **** error ", e);
        },
        onabort: function (e) {
          console.error("[FF Scouter V2] **** abort ", e);
        },
        ontimeout: function (e) {
          console.error("[FF Scouter V2] **** timeout ", e);
        },
      });
    } else {
      callback(player_ids);
    }
  }

  function display_fair_fight(target_id, player_id) {
    const response = get_cached_value(target_id);
    if (response) {
      set_fair_fight(response, player_id);
    }
  }

  function get_ff_string(ff_response) {
    const ff = ff_response.value.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_difficulty_text(ff) {
    if (ff <= 1) {
      return "Extremely easy";
    } else if (ff <= 2) {
      return "Easy";
    } else if (ff <= 3.5) {
      return "Moderately difficult";
    } else if (ff <= 4.5) {
      return "Difficult";
    } else {
      return "May be impossible";
    }
  }

  function get_detailed_message(ff_response, player_id) {
    if (ff_response.no_data || !ff_response.value) {
      return `<span style=\"font-weight: bold; margin-right: 6px;\">FairFight:</span><span style=\"background: #444; color: #fff; font-weight: bold; padding: 2px 6px; border-radius: 4px; display: inline-block;\">No data</span>`;
    }
    const ff_string = get_ff_string(ff_response);
    const difficulty = get_difficulty_text(ff_response.value);

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

    const background_colour = get_ff_colour(ff_response.value);
    const text_colour = get_contrast_color(background_colour);

    let statDetails = "";
    if (ff_response.bs_estimate_human) {
      statDetails = `<span style=\"font-size: 11px; font-weight: normal; margin-left: 8px; vertical-align: middle; color: #cccccc; font-style: italic;\">Est. Stats: <span>${ff_response.bs_estimate_human}</span></span>`;
    }

    return `<span style=\"font-weight: bold; margin-right: 6px;\">FairFight:</span><span style=\"background: ${background_colour}; color: ${text_colour}; font-weight: bold; padding: 2px 6px; border-radius: 4px; display: inline-block;\">${ff_string} (${difficulty}) ${fresh}</span>${statDetails}`;
  }

  function get_ff_string_short(ff_response, player_id) {
    const ff = ff_response.value.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 set_fair_fight(ff_response, player_id) {
    const detailed_message = get_detailed_message(ff_response, player_id);
    info_line.innerHTML = 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 get_cached_value(player_id) {
    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.value &&
      !cached_ff_response.no_data &&
      cached_ff_response.expiry > Date.now()
    ) {
      return cached_ff_response;
    }
    return null;
  }

  function apply_fair_fight_info(_) {
    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 (_, 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");

      const cached = get_cached_value(player_id);
      if (cached) {
        const ff = cached.value;
        const ff_string = get_ff_string_short(cached, 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";
        fair_fight_div.innerHTML = ff_string;
      }

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

  function get_cache_misses(player_ids) {
    var unknown_player_ids = [];
    for (const player_id of player_ids) {
      if (!get_cached_value(player_id)) {
        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], target_id);
    });

    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 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 cached = get_cached_value(player_id);
      if (cached) {
        const percent = ff_to_percent(cached.value);
        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_cached_value(player_id);
      if (response) {
        // Remove any existing elements
        $(mini).find(".ff-scouter-mini-ff").remove();

        // Minimal, text-only Fair Fight string for mini-profiles
        const ff_string = get_ff_string(response);
        const difficulty = get_difficulty_text(response.value);
        const now = Date.now() / 1000;
        const age = now - response.last_updated;
        let fresh = "";
        if (age < 24 * 60 * 60) {
          // Pass
        } else if (age < 31 * 24 * 60 * 60) {
          var days = Math.round(age / (24 * 60 * 60));
          fresh = days === 1 ? "(1 day old)" : `(${days} days old)`;
        } else if (age < 365 * 24 * 60 * 60) {
          var months = Math.round(age / (31 * 24 * 60 * 60));
          fresh = months === 1 ? "(1 month old)" : `(${months} months old)`;
        } else {
          var years = Math.round(age / (365 * 24 * 60 * 60));
          fresh = years === 1 ? "(1 year old)" : `(${years} years old)`;
        }
        const message = `FF ${ff_string} (${difficulty}) ${fresh}`;

        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.includes("page.php?sid=hof")) {
        await apply_ff_gauge($('[class^="userInfoBox__"]').toArray());
      }
    }

    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,
  });

  function abbreviateCountry(name) {
    if (!name) return "";
    if (name.trim().toLowerCase() === "switzerland") return "Switz";
    const words = name.trim().split(/\s+/);
    if (words.length === 1) return words[0];
    return words.map((w) => w[0].toUpperCase()).join("");
  }

  function formatTime(ms) {
    let totalSeconds = Math.max(0, Math.floor(ms / 1000));
    let hours = String(Math.floor(totalSeconds / 3600)).padStart(2, "0");
    let minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(
      2,
      "0",
    );
    let seconds = String(totalSeconds % 60).padStart(2, "0");
    return `${hours}:${minutes}:${seconds}`;
  }

  function fetchFactionData(factionID) {
    const url = `https://api.torn.com/v2/faction/${factionID}/members?striptags=true&key=${key}`;
    return fetch(url).then((response) => response.json());
  }

  function updateMemberStatus(li, member) {
    if (!member || !member.status) return;

    let statusEl = li.querySelector(".status");
    if (!statusEl) return;

    let lastActionRow = li.querySelector(".last-action-row");
    let lastActionText = member.last_action?.relative || "";
    if (lastActionRow) {
      lastActionRow.textContent = `Last Action: ${lastActionText}`;
    } else {
      lastActionRow = document.createElement("div");
      lastActionRow.className = "last-action-row";
      lastActionRow.textContent = `Last Action: ${lastActionText}`;
      let lastDiv = Array.from(li.children)
        .reverse()
        .find((el) => el.tagName === "DIV");
      if (lastDiv?.nextSibling) {
        li.insertBefore(lastActionRow, lastDiv.nextSibling);
      } else {
        li.appendChild(lastActionRow);
      }
    }

    // Handle status changes
    if (member.status.state === "Okay") {
      if (statusEl.dataset.originalHtml) {
        statusEl.innerHTML = statusEl.dataset.originalHtml;
        delete statusEl.dataset.originalHtml;
      }
      statusEl.textContent = "Okay";
    } else if (member.status.state === "Traveling") {
      if (!statusEl.dataset.originalHtml) {
        statusEl.dataset.originalHtml = statusEl.innerHTML;
      }

      let description = member.status.description || "";
      let location = "";
      let isReturning = false;

      if (description.includes("Returning to Torn from ")) {
        location = description.replace("Returning to Torn from ", "");
        isReturning = true;
      } else if (description.includes("Traveling to ")) {
        location = description.replace("Traveling to ", "");
      }

      let abbr = abbreviateCountry(location);
      const planeSvg = `<svg class="plane-svg ${isReturning ? "returning" : ""}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
                    <path d="M482.3 192c34.2 0 93.7 29 93.7 64c0 36-59.5 64-93.7 64l-116.6 0L265.2 495.9c-5.7 10-16.3 16.1-27.8 16.1l-56.2 0c-10.6 0-18.3-10.2-15.4-20.4l49-171.6L112 320 68.8 377.6c-3 4-7.8 6.4-12.8 6.4l-42 0c-7.8 0-14-6.3-14-14c0-1.3 .2-2.6 .5-3.9L32 256 .5 145.9c-.4-1.3-.5-2.6-.5-3.9c0-7.8 6.3-14 14-14l42 0c5 0 9.8 2.4 12.8 6.4L112 192l102.9 0-49-171.6C162.9 10.2 170.6 0 181.2 0l56.2 0c11.5 0 22.1 6.2 27.8 16.1L365.7 192l116.6 0z"/>
                </svg>`;
      const tornSymbol = `<svg class="torn-symbol" viewBox="0 0 24 24">
                    <circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
                    <text x="12" y="16" text-anchor="middle" font-family="Arial" font-weight="bold" font-size="14" fill="currentColor">T</text>
                </svg>`;
      statusEl.innerHTML = `<span class="travel-status">${tornSymbol}${planeSvg}<span class="country-abbr">${abbr}</span></span>`;
    } else if (member.status.state === "Abroad") {
      if (!statusEl.dataset.originalHtml) {
        statusEl.dataset.originalHtml = statusEl.innerHTML;
      }
      let description = member.status.description || "";
      if (description.startsWith("In ")) {
        let location = description.replace("In ", "");
        let abbr = abbreviateCountry(location);
        statusEl.textContent = `in ${abbr}`;
      }
    }

    // Update countdown
    if (member.status.until && parseInt(member.status.until, 10) > 0) {
      memberCountdowns[member.id] = parseInt(member.status.until, 10);
    } else {
      delete memberCountdowns[member.id];
    }
  }

  function updateFactionStatuses(factionID, container) {
    apiCallInProgressCount++;
    fetchFactionData(factionID)
      .then((data) => {
        if (!Array.isArray(data.members)) {
          console.warn(
            `[FF Scouter V2] No members array for faction ${factionID}`,
          );
          return;
        }

        const memberMap = {};
        data.members.forEach((member) => {
          memberMap[member.id] = member;
        });

        container.querySelectorAll("li").forEach((li) => {
          let profileLink = li.querySelector('a[href*="profiles.php?XID="]');
          if (!profileLink) return;
          let match = profileLink.href.match(/XID=(\d+)/);
          if (!match) return;
          let userID = match[1];
          updateMemberStatus(li, memberMap[userID]);
        });
      })
      .catch((err) => {
        console.error(
          "[FF Scouter V2] Error fetching faction data for faction",
          factionID,
          err,
        );
      })
      .finally(() => {
        apiCallInProgressCount--;
      });
  }

  function updateAllMemberTimers() {
    const liElements = document.querySelectorAll(
      ".enemy-faction .members-list li, .your-faction .members-list li",
    );
    liElements.forEach((li) => {
      let profileLink = li.querySelector('a[href*="profiles.php?XID="]');
      if (!profileLink) return;
      let match = profileLink.href.match(/XID=(\d+)/);
      if (!match) return;
      let userID = match[1];
      let statusEl = li.querySelector(".status");
      if (!statusEl) return;
      if (memberCountdowns[userID]) {
        let remaining = memberCountdowns[userID] * 1000 - Date.now();
        if (remaining < 0) remaining = 0;
        statusEl.textContent = formatTime(remaining);
      }
    });
  }

  function updateAPICalls() {
    let enemyFactionLink = document.querySelector(
      ".opponentFactionName___vhESM",
    );
    let yourFactionLink = document.querySelector(".currentFactionName___eq7n8");
    if (!enemyFactionLink || !yourFactionLink) return;

    let enemyFactionIdMatch = enemyFactionLink.href.match(/ID=(\d+)/);
    let yourFactionIdMatch = yourFactionLink.href.match(/ID=(\d+)/);
    if (!enemyFactionIdMatch || !yourFactionIdMatch) return;

    let enemyList = document.querySelector(".enemy-faction .members-list");
    let yourList = document.querySelector(".your-faction .members-list");
    if (!enemyList || !yourList) return;

    updateFactionStatuses(enemyFactionIdMatch[1], enemyList);
    updateFactionStatuses(yourFactionIdMatch[1], yourList);
  }

  function initWarScript() {
    let enemyFactionLink = document.querySelector(
      ".opponentFactionName___vhESM",
    );
    let yourFactionLink = document.querySelector(".currentFactionName___eq7n8");
    if (!enemyFactionLink || !yourFactionLink) return false;

    let enemyList = document.querySelector(".enemy-faction .members-list");
    let yourList = document.querySelector(".your-faction .members-list");
    if (!enemyList || !yourList) return false;

    updateAPICalls();
    setInterval(updateAPICalls, API_INTERVAL);
    console.log(
      "[FF Scouter V2] Torn Faction Status Countdown (Real-Time & API Status - Relative Last): Initialized",
    );
    return true;
  }

  let warObserver = new MutationObserver((mutations, obs) => {
    if (initWarScript()) {
      obs.disconnect();
    }
  });
  if (!document.getElementById("FFScouterV2DisableWarMonitor")) {
    warObserver.observe(document.body, { childList: true, subtree: true });

    const memberTimersInterval = setInterval(updateAllMemberTimers, 1000);

    window.addEventListener("FFScouterV2DisableWarMonitor", () => {
      console.log(
        "[FF Scouter V2] Caught disable event, removing monitoring observer and interval",
      );
      warObserver.disconnect();

      clearInterval(memberTimersInterval);
    });
  }
  // Try to be friendly and detect other war monitoring scripts
  const catchOtherScripts = () => {
    if (
      Array.from(document.querySelectorAll("style")).some(
        (style) =>
          style.textContent.includes(
            '.members-list li:has(div.status[data-twse-highlight="true"])', // Torn War Stuff Enhanced
          ) ||
          style.textContent.includes(".warstuff_highlight") || // Torn War Stuff
          style.textContent.includes(".finally-bs-stat"), // wall-battlestats
      )
    ) {
      window.dispatchEvent(new Event("FFScouterV2DisableWarMonitor"));
    }
  };
  catchOtherScripts();
  setTimeout(catchOtherScripts, 500);

  function showToast(message) {
    const existing = document.getElementById("ffscouter-toast");
    if (existing) existing.remove();

    const toast = document.createElement("div");
    toast.id = "ffscouter-toast";
    toast.style.position = "fixed";
    toast.style.bottom = "30px";
    toast.style.left = "50%";
    toast.style.transform = "translateX(-50%)";
    toast.style.background = "#c62828";
    toast.style.color = "#fff";
    toast.style.padding = "8px 16px";
    toast.style.borderRadius = "8px";
    toast.style.fontSize = "14px";
    toast.style.boxShadow = "0 2px 12px rgba(0,0,0,0.2)";
    toast.style.zIndex = "2147483647";
    toast.style.opacity = "1";
    toast.style.transition = "opacity 0.5s";
    toast.style.display = "flex";
    toast.style.alignItems = "center";
    toast.style.gap = "10px";

    // Close button
    const closeBtn = document.createElement("span");
    closeBtn.textContent = "×";
    closeBtn.style.cursor = "pointer";
    closeBtn.style.marginLeft = "8px";
    closeBtn.style.fontWeight = "bold";
    closeBtn.style.fontSize = "18px";
    closeBtn.setAttribute("aria-label", "Close");
    closeBtn.onclick = () => toast.remove();

    const msg = document.createElement("span");
    if (
      message ===
      "Invalid API key. Please sign up at ffscouter.com to use this service"
    ) {
      msg.innerHTML =
        'FairFight Scouter: Invalid API key. Please sign up at <a href="https://ffscouter.com" target="_blank" style="color: #fff; text-decoration: underline; font-weight: bold;">ffscouter.com</a> to use this service';
    } else {
      msg.textContent = `FairFight Scouter: ${message}`;
    }

    toast.appendChild(msg);
    toast.appendChild(closeBtn);
    document.body.appendChild(toast);
    setTimeout(() => {
      if (toast.parentNode) {
        toast.style.opacity = "0";
        setTimeout(() => toast.remove(), 500);
      }
    }, 4000);
  }
}