// ==UserScript==
// @name FF Scouter V2
// @namespace Violentmonkey Scripts
// @match https://www.torn.com/*
// @version 2.60
// @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_listValues
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @connect ffscouter.com
// @license GPL-3.0
// ==/UserScript==
const FF_VERSION = "2.60";
const API_INTERVAL = 30000;
const FF_TARGET_STALENESS = 24 * 60 * 60 * 1000; // Refresh the target list every day
const TARGET_KEY = "ffscouterv2-targets";
const memberCountdowns = {};
let apiCallInProgressCount = 0;
let currentUserId = null;
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-ff-visible {
display: flex !important;
}
.ff-scouter-ff-hidden {
display: none !important;
}
.ff-scouter-est-visible {
display: flex !important;
}
.ff-scouter-est-hidden {
display: none !important;
}
.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;
}
/* FF Scouter CSS Variables */
body {
--ff-bg-color: #f0f0f0;
--ff-alt-bg-color: #fff;
--ff-border-color: #ccc;
--ff-input-color: #ccc;
--ff-text-color: #000;
--ff-hover-color: #ddd;
--ff-glow-color: #4CAF50;
--ff-success-color: #4CAF50;
}
body.dark-mode {
--ff-bg-color: #333;
--ff-alt-bg-color: #383838;
--ff-border-color: #444;
--ff-input-color: #504f4f;
--ff-text-color: #ccc;
--ff-hover-color: #555;
--ff-glow-color: #4CAF50;
--ff-success-color: #4CAF50;
}
.ff-settings-accordion {
margin: 10px 0;
padding: 10px;
background-color: var(--ff-bg-color);
border: 1px solid var(--ff-border-color);
border-radius: 5px;
}
.ff-settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
margin-bottom: 10px;
font-size: 1.2em;
font-weight: bold;
color: var(--ff-text-color);
}
.ff-settings-header-username {
display: inline;
font-style: italic;
color: var(--ff-success-color);
}
.ff-settings-entry {
display: flex;
align-items: center;
gap: 5px;
margin-top: 10px;
margin-bottom: 5px;
}
.ff-settings-entry p {
margin: 0;
color: var(--ff-text-color);
}
.ff-settings-input {
width: 120px;
padding: 5px;
background-color: var(--ff-input-color);
color: var(--ff-text-color);
border: 1px solid var(--ff-border-color);
border-radius: 3px;
}
.ff-settings-input.ff-blur {
filter: blur(3px);
transition: filter 0.5s;
}
.ff-settings-input.ff-blur:focus {
filter: blur(0);
transition: filter 0.5s;
}
.ff-settings-button {
padding: 5px 10px;
transition: background-color 0.5s;
background-color: var(--ff-bg-color);
cursor: pointer;
border: 1px solid var(--ff-border-color);
border-radius: 5px;
color: var(--ff-text-color);
margin-right: 10px;
}
.ff-settings-button:hover {
background-color: var(--ff-hover-color);
}
.ff-settings-button:last-child {
margin-right: 0;
}
.ff-settings-glow {
animation: ff-glow 1s infinite alternate;
border-width: 3px;
}
@keyframes ff-glow {
0% {
border-color: var(--ff-border-color);
}
100% {
border-color: var(--ff-glow-color);
}
}
.ff-api-explanation {
background-color: var(--ff-alt-bg-color);
border: 1px solid var(--ff-border-color);
border-radius: 8px;
color: var(--ff-text-color);
margin-bottom: 20px;
}
.ff-api-explanation a {
color: var(--ff-success-color) !important;
text-decoration: underline;
}
.ff-settings-label {
color: var(--ff-text-color);
}
.ff-settings-section-header {
color: var(--ff-text-color);
margin-top: 20px;
margin-bottom: 10px;
font-weight: bold;
}
.ff-settings-entry-large {
margin-bottom: 15px;
}
.ff-settings-entry-small {
margin-bottom: 10px;
}
.ff-settings-entry-section {
margin-bottom: 20px;
}
.ff-settings-label-inline {
margin-right: 10px;
min-width: 150px;
display: inline-block;
}
.ff-settings-input-wide {
width: 200px;
}
.ff-settings-input-narrow {
width: 120px;
}
.ff-settings-checkbox {
margin-right: 8px;
}
.ff-settings-button-large {
padding: 8px 16px;
font-size: 14px;
font-weight: bold;
}
.ff-settings-button-container {
margin-bottom: 20px;
text-align: center;
}
.ff-api-explanation-content {
padding: 12px 16px;
font-size: 13px;
line-height: 1.5;
}
`);
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_listValues;
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] Generic error handler: ", 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] Generic error handler: ", 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_listValues = function () {
const keys = [];
for (const key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
keys.push(key);
}
}
return keys;
};
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_listValues = GM_listValues;
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(
"[FF Scouter V2]: 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.style.cursor = "pointer";
info_line.addEventListener("click", () => {
if (!key) {
const limited_key = prompt(
"[FF Scouter V2]: Enter Limited API Key",
rD_getValue("limited_key", ""),
);
if (limited_key) {
rD_setValue("limited_key", limited_key);
key = limited_key;
window.location.reload();
}
} else {
configure_ranges();
}
});
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 configure_ranges() {
const values = get_ff_ranges(true);
let curSetting = "";
if (values) {
curSetting = `${values.low},${values.high},${values.max}`;
}
const response = prompt(
"Enter the low, high, and max FF you want to use, separated by commas. Empty resets to default (Default '2,4,8').",
curSetting,
);
// They hit cancel
if (response == null) {
return;
}
if (response == "") {
reset_ff_ranges();
return;
}
const split = response.split(",");
if (split.length != 3) {
showToast(
"Incorrect format: FF scouter ranges should be 3 numbers separated by commas [<low>,<high>,<max>]",
);
return;
}
let low = null;
try {
low = parseFloat(split[0]);
} catch (e) {
showToast("Incorrect format: FF scouter low value must be a float.");
return;
}
let high = null;
try {
high = parseFloat(split[1]);
} catch (e) {
showToast("Incorrect format: FF scouter high value must be a float.");
return;
}
let max = null;
try {
max = parseFloat(split[2]);
} catch (e) {
showToast("Incorrect format: FF scouter max value must be a float.");
return;
}
set_ff_ranges(low, high, max);
}
function reset_ff_ranges() {
rD_deleteValue("ffscouterv2-ranges");
}
function set_ff_ranges(low, high, max) {
rD_setValue(
"ffscouterv2-ranges",
JSON.stringify({ low: low, high: high, max: max }),
);
}
function get_ff_ranges(noDefault) {
const defaultRange = { low: 2, high: 4, max: 8 };
const rangeUnparsed = rD_getValue("ffscouterv2-ranges");
if (!rangeUnparsed) {
if (noDefault) {
return null;
}
return defaultRange;
}
try {
const parsed = JSON.parse(rangeUnparsed);
return parsed;
} catch (error) {
console.log(
"[FF Scouter V2] Problem parsing configured range, reseting values.",
);
reset_ff_ranges();
if (noDefault) {
return null;
}
return defaultRange;
}
}
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)];
clean_expired_data();
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) {
// If the same request happens in under a second, Torn PDA will return nothing
return;
}
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(
"ffscouterv2-" + 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(
"ffscouterv2-" + result.player_id,
JSON.stringify(cacheObj),
);
}
}
});
callback(player_ids);
} else {
try {
var err = JSON.parse(response.responseText);
if (err && err.error) {
showToast(
"API request failed. Error: " +
err.error +
"; Code: " +
err.code,
);
} else {
showToast(
"API request failed. HTTP status code: " + response.status,
);
}
} catch {
showToast(
"API request failed. HTTP status code: " + response.status,
);
}
}
},
onerror: function (e) {
console.error("[FF Scouter V2] **** error ", e, "; Stack:", e.stack);
},
onabort: function (e) {
console.error("[FF Scouter V2] **** abort ", e, "; Stack:", e.stack);
},
ontimeout: function (e) {
console.error(
"[FF Scouter V2] **** timeout ",
e,
"; Stack:",
e.stack,
);
},
});
} else {
callback(player_ids);
}
}
function clean_expired_data() {
let count = 0;
for (const key of rD_listValues()) {
// Try renaming the key to the new name format
if (key.match(/^\d+$/)) {
if (rename_if_ffscouter(key)) {
if (clear_if_expired("ffscouterv2-" + key)) {
count++;
}
}
}
if (key.startsWith("ffscouterv2-")) {
if (clear_if_expired(key)) {
count++;
}
}
}
console.log("[FF Scouter V2] Cleaned " + count + " expired values");
}
function rename_if_ffscouter(key) {
const value = rD_getValue(key, null);
if (value == null) {
return false;
}
var parsed = null;
try {
parsed = JSON.parse(value);
} catch {
return false;
}
if (parsed == null) {
return false;
}
if ((!parsed.value && !parsed.no_data) || !parsed.expiry) {
return false;
}
rD_setValue("ffscouterv2-" + key, value);
rD_deleteValue(key);
return true;
}
function clear_if_expired(key) {
const value = rD_getValue(key, null);
var parsed = null;
try {
parsed = JSON.parse(value);
} catch {
return false;
}
if (
parsed &&
(parsed.value || parsed.no_data) &&
parsed.expiry &&
parsed.expiry < Date.now()
) {
rD_deleteValue(key);
return true;
}
return false;
}
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; 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 > 99) {
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("ffscouterv2-" + 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()) {
return cached_ff_response;
}
return null;
}
function apply_fair_fight_info(_) {
var ff_li = document.createElement("li");
ff_li.tabIndex = "0";
ff_li.classList.add("table-cell");
ff_li.classList.add("lvl");
ff_li.classList.add("torn-divider");
ff_li.classList.add("divider-vertical");
ff_li.classList.add("c-pointer");
ff_li.classList.add("ff-scouter-ff-visible");
ff_li.onclick = () => {
$(".ff-scouter-ff-visible").each(function (_, value) {
value.classList.remove("ff-scouter-ff-visible");
value.classList.add("ff-scouter-ff-hidden");
});
$(".ff-scouter-est-hidden").each(function (_, value) {
value.classList.remove("ff-scouter-est-hidden");
value.classList.add("ff-scouter-est-visible");
});
};
ff_li.appendChild(document.createTextNode("FF"));
var est_li = document.createElement("li");
est_li.tabIndex = "0";
est_li.classList.add("table-cell");
est_li.classList.add("lvl");
est_li.classList.add("torn-divider");
est_li.classList.add("divider-vertical");
est_li.classList.add("c-pointer");
est_li.classList.add("ff-scouter-est-hidden");
est_li.onclick = () => {
$(".ff-scouter-ff-hidden").each(function (_, value) {
value.classList.remove("ff-scouter-ff-hidden");
value.classList.add("ff-scouter-ff-visible");
});
$(".ff-scouter-est-visible").each(function (_, value) {
value.classList.remove("ff-scouter-est-visible");
value.classList.add("ff-scouter-est-hidden");
});
};
est_li.appendChild(document.createTextNode("Est"));
if ($(".table-header > .lvl").length == 0) {
// The .member-list doesn't have a .lvl, give up
return;
}
$(".table-header > .lvl")[0].after(ff_li, est_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");
fair_fight_div.classList.add("ff-scouter-ff-visible");
var estimate_div = document.createElement("div");
estimate_div.classList.add("table-cell");
estimate_div.classList.add("lvl");
estimate_div.classList.add("ff-scouter-est-hidden");
const cached = get_cached_value(player_id);
if (cached && cached.value) {
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;
if (cached.bs_estimate_human) {
estimate_div.innerHTML = cached.bs_estimate_human;
}
}
value.nextSibling.after(fair_fight_div, estimate_div);
});
}
function get_cache_misses(player_ids) {
var unknown_player_ids = [];
for (const player_id of player_ids) {
const cached = get_cached_value(player_id);
if (!cached || !cached.value) {
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("[FF Scouter V2]: 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("[FF Scouter V2]: 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;
}
const matchUserId = anchor.href.match(/.*userId=(?<target_id>\d+)/);
if (matchUserId) {
return matchUserId.groups.target_id;
}
}
if (element.nodeName.toLowerCase() === "a") {
const match = element.href.match(/.*XID=(?<target_id>\d+)/);
if (match) {
return match.groups.target_id;
}
const matchUserId = element.href.match(/.*userId=(?<target_id>\d+)/);
if (matchUserId) {
return matchUserId.groups.target_id;
}
}
return null;
}
function ff_to_percent(ff) {
// The percent is 0-33% 33-66% 66%-100%
// With configurable ranges there are no guarantees that the sections are linear
const stored_values = get_ff_ranges();
const low_ff = stored_values.low;
const high_ff = stored_values.high;
const low_mid_percent = 33;
const mid_high_percent = 66;
ff = Math.min(ff, stored_values.max);
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) / (stored_values.max - 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 && cached.value) {
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 && response.value) {
// 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/loader.php?sid=attackLog",
)
) {
const participants = $("ul.participants-list li").toArray();
if (participants > 100) {
return;
}
await apply_ff_gauge(participants);
} 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());
}
}
if (
window.location.href.startsWith(
"https://www.torn.com/page.php?sid=ItemMarket",
)
) {
await apply_ff_gauge(
$(
"div.bazaar-listing-card div:first-child div:first-child > a",
).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 get_cached_targets(staleok) {
const value = rD_getValue(TARGET_KEY);
if (!value) {
return null;
}
let parsed = null;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
if (parsed == null) {
return null;
}
if (staleok) {
return parsed.targets;
}
if (parsed.last_updated + FF_TARGET_STALENESS > new Date()) {
// Old cache, return nothing
return null;
}
return parsed.targets;
}
function update_ff_targets() {
if (!key) {
return;
}
const cached = get_cached_targets(false);
if (cached) {
return;
}
const url = `${BASE_URL}/api/v1/get-targets?key=${key}&inactiveonly=1&maxff=2.5&limit=50`;
console.log("[FF Scouter V2] Refreshing chain list");
rD_xmlhttpRequest({
method: "GET",
url: url,
onload: function (response) {
if (!response) {
return;
}
if (response.status == 200) {
var ff_response = JSON.parse(response.responseText);
if (ff_response && ff_response.error) {
showToast(ff_response.error);
return;
}
if (ff_response.targets) {
const result = {
targets: ff_response.targets,
last_updated: new Date(),
};
rD_setValue(TARGET_KEY, JSON.stringify(result));
console.log("[FF Scouter V2] Chain list updated successfully");
}
} else {
try {
var err = JSON.parse(response.responseText);
if (err && err.error) {
showToast(
"API request failed. Error: " +
err.error +
"; Code: " +
err.code,
);
} else {
showToast(
"API request failed. HTTP status code: " + response.status,
);
}
} catch {
showToast(
"API request failed. HTTP status code: " + response.status,
);
}
}
},
onerror: function (e) {
console.error("[FF Scouter V2] **** error ", e, "; Stack:", e.stack);
},
onabort: function (e) {
console.error("[FF Scouter V2] **** abort ", e, "; Stack:", e.stack);
},
ontimeout: function (e) {
console.error("[FF Scouter V2] **** timeout ", e, "; Stack:", e.stack);
},
});
}
function get_random_chain_target() {
const targets = get_cached_targets(true);
if (!targets) {
return null;
}
const r = Math.floor(Math.random() * targets.length);
return targets[r];
}
// Chain button stolen from https://greasyfork.org/en/scripts/511916-random-target-finder
function create_chain_button() {
// Check if chain button is enabled in settings
if (!ffSettingsGetToggle("chain-button-enabled")) {
console.log("[FF Scouter V2] Chain button disabled in settings");
return;
}
const button = document.createElement("button");
button.innerHTML = "FF";
button.style.position = "fixed";
//button.style.top = '10px';
//button.style.right = '10px';
button.style.top = "32%"; // Adjusted to center vertically
button.style.right = "0%"; // Center horizontally
//button.style.transform = 'translate(-50%, -50%)'; // Center the button properly
button.style.zIndex = "9999";
// Add CSS styles for a green background
button.style.backgroundColor = "green";
button.style.color = "white";
button.style.border = "none";
button.style.padding = "6px";
button.style.borderRadius = "6px";
button.style.cursor = "pointer";
// Add a click event listener to open Google in a new tab
button.addEventListener("click", function () {
let rando = get_random_chain_target();
if (!rando) {
return;
}
const linkType = ffSettingsGet("chain-link-type") || "attack";
const tabType = ffSettingsGet("chain-tab-type") || "newtab";
let profileLink;
if (linkType === "profile") {
profileLink = `https://www.torn.com/profiles.php?XID=${rando.player_id}`;
} else {
profileLink = `https://www.torn.com/loader.php?sid=attack&user2ID=${rando.player_id}`;
}
if (tabType === "sametab") {
window.location.href = profileLink;
} else {
window.open(profileLink, "_blank");
}
});
// Add the button to the page
document.body.appendChild(button);
}
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();
}
});
// Only initialize war monitoring if enabled in settings
if (
!document.getElementById("FFScouterV2DisableWarMonitor") &&
ffSettingsGetToggle("war-monitor-enabled")
) {
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 waitForElement(querySelector, timeout = 15000) {
return new Promise((resolve) => {
// Check if element already exists
const existingElement = document.querySelector(querySelector);
if (existingElement) {
return resolve(existingElement);
}
// Set up observer to watch for element
const observer = new MutationObserver(() => {
const element = document.querySelector(querySelector);
if (element) {
observer.disconnect();
if (timer) {
clearTimeout(timer);
}
resolve(element);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Set up timeout
const timer = setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
async function getLocalUserId() {
const profileLink = await waitForElement(
".settings-menu > .link > a:first-child",
15000,
);
if (!profileLink) {
console.log(
"[FF Scouter V2] Could not find profile link in settings menu",
);
return null;
}
const match = profileLink.href.match(/XID=(\d+)/);
if (match) {
const userId = match[1];
console.log(`[FF Scouter V2] Found local user ID: ${userId}`);
return userId;
}
console.log("[FF Scouter V2] Could not extract user ID from profile link");
return null;
}
function getCurrentUserId() {
return currentUserId;
}
// Settings management utilities
function ffSettingsGet(key) {
return rD_getValue(`ffscouterv2-${key}`, null);
}
function ffSettingsSet(key, value) {
rD_setValue(`ffscouterv2-${key}`, value);
}
function ffSettingsGetToggle(key) {
return ffSettingsGet(key) === "true";
}
function ffSettingsSetToggle(key, value) {
ffSettingsSet(key, value.toString());
}
async function createSettingsPanel() {
// Check if we're on the user's own profile page
const pageId = window.location.href.match(/XID=(\d+)/)?.[1];
if (!pageId || pageId !== currentUserId) {
return;
}
// Wait for profile wrapper to be available
const profileWrapper = await waitForElement(".profile-wrapper", 15000);
if (!profileWrapper) {
console.log(
"[FF Scouter V2] Could not find profile wrapper for settings panel",
);
return;
}
// Check if settings panel already exists
if (
profileWrapper.nextElementSibling?.classList.contains(
"ff-settings-accordion",
)
) {
console.log("[FF Scouter V2] Settings panel already exists");
return;
}
// Get current user data for display
const userName =
profileWrapper.querySelector(".user-name")?.textContent ||
profileWrapper.querySelector(".profile-name")?.textContent ||
profileWrapper.querySelector("h1")?.textContent ||
"User";
// Create the settings panel
const settingsPanel = document.createElement("details");
settingsPanel.className = "ff-settings-accordion";
// Add glow effect if API key is not set
if (!key) {
settingsPanel.classList.add("ff-settings-glow");
}
// Create summary
const summary = document.createElement("summary");
summary.textContent = "FF Scouter Settings";
settingsPanel.appendChild(summary);
// Create main content div
const content = document.createElement("div");
// API Key Explanation
const apiExplanation = document.createElement("div");
apiExplanation.className = "ff-api-explanation ff-api-explanation-content";
apiExplanation.innerHTML = `
<strong>Important:</strong> You must use the SAME exact API key that you use on
<a href="https://ffscouter.com/" target="_blank">ffscouter.com</a>.
<br><br>
If you're not sure which API key you used, go to
<a href="https://www.torn.com/preferences.php#tab=api" target="_blank">your API preferences</a>
and look for "FFScouter3" in your API key history comments.
`;
content.appendChild(apiExplanation);
// API Key Input
const apiKeyDiv = document.createElement("div");
apiKeyDiv.className = "ff-settings-entry ff-settings-entry-large";
const apiKeyLabel = document.createElement("label");
apiKeyLabel.setAttribute("for", "ff-api-key");
apiKeyLabel.textContent = "FF Scouter API Key:";
apiKeyLabel.className = "ff-settings-label ff-settings-label-inline";
apiKeyDiv.appendChild(apiKeyLabel);
const apiKeyInput = document.createElement("input");
apiKeyInput.type = "text";
apiKeyInput.id = "ff-api-key";
apiKeyInput.placeholder = "Paste your key here...";
apiKeyInput.className = "ff-settings-input ff-settings-input-wide";
apiKeyInput.value = key || "";
// Add blur class if key exists
if (key) {
apiKeyInput.classList.add("ff-blur");
}
apiKeyInput.addEventListener("focus", function () {
this.classList.remove("ff-blur");
});
apiKeyInput.addEventListener("blur", function () {
if (this.value) {
this.classList.add("ff-blur");
}
});
apiKeyInput.addEventListener("change", function () {
const newKey = this.value;
if (typeof newKey !== "string") {
return;
}
if (newKey && newKey.length < 10) {
this.style.outline = "1px solid red";
return;
}
this.style.outline = "none";
if (newKey === key) return;
rD_setValue("limited_key", newKey);
key = newKey;
if (newKey) {
this.classList.add("ff-blur");
settingsPanel.classList.remove("ff-settings-glow");
} else {
settingsPanel.classList.add("ff-settings-glow");
}
});
apiKeyDiv.appendChild(apiKeyInput);
content.appendChild(apiKeyDiv);
const rangesDiv = document.createElement("div");
rangesDiv.className = "ff-settings-entry ff-settings-entry-large";
const rangesLabel = document.createElement("label");
rangesLabel.setAttribute("for", "ff-ranges");
rangesLabel.textContent = "FF Ranges (Low, High, Max):";
rangesLabel.className = "ff-settings-label ff-settings-label-inline";
rangesDiv.appendChild(rangesLabel);
const rangesInput = document.createElement("input");
rangesInput.type = "text";
rangesInput.id = "ff-ranges";
rangesInput.placeholder = "2,4,8";
rangesInput.className = "ff-settings-input ff-settings-input-narrow";
// Set current values
const currentRanges = get_ff_ranges(true);
if (currentRanges) {
rangesInput.value = `${currentRanges.low},${currentRanges.high},${currentRanges.max}`;
}
rangesInput.addEventListener("change", function () {
const value = this.value;
if (value === "") {
reset_ff_ranges();
this.style.outline = "none";
return;
}
const parts = value.split(",").map((p) => p.trim());
if (parts.length !== 3) {
this.style.outline = "1px solid red";
showToast(
"Incorrect format: FF ranges should be exactly 3 numbers separated by commas [low,high,max]",
);
return;
}
try {
const low = parseFloat(parts[0]);
const high = parseFloat(parts[1]);
const max = parseFloat(parts[2]);
if (isNaN(low) || isNaN(high) || isNaN(max)) {
throw new Error("Invalid numbers");
}
if (low <= 0 || high <= 0 || max <= 0) {
this.style.outline = "1px solid red";
showToast("FF ranges must be positive numbers");
return;
}
if (low >= high || high >= max) {
this.style.outline = "1px solid red";
showToast("FF ranges must be in ascending order: low < high < max");
return;
}
set_ff_ranges(low, high, max);
this.style.outline = "none";
showToast("FF ranges updated successfully!");
} catch (e) {
this.style.outline = "1px solid red";
showToast("Invalid numbers in FF ranges");
}
});
rangesDiv.appendChild(rangesInput);
content.appendChild(rangesDiv);
// Feature Toggles
const featuresLabel = document.createElement("p");
featuresLabel.textContent = "Feature toggles:";
featuresLabel.className = "ff-settings-section-header";
content.appendChild(featuresLabel);
// Chain Button Toggle
const chainToggleDiv = document.createElement("div");
chainToggleDiv.className = "ff-settings-entry ff-settings-entry-small";
const chainToggle = document.createElement("input");
chainToggle.type = "checkbox";
chainToggle.id = "chain-button-toggle";
chainToggle.checked = ffSettingsGetToggle("chain-button-enabled");
chainToggle.className = "ff-settings-checkbox";
const chainLabel = document.createElement("label");
chainLabel.setAttribute("for", "chain-button-toggle");
chainLabel.textContent = "Enable Chain Button (Green FF Button)";
chainLabel.className = "ff-settings-label";
chainLabel.style.cursor = "pointer";
chainToggleDiv.appendChild(chainToggle);
chainToggleDiv.appendChild(chainLabel);
content.appendChild(chainToggleDiv);
const chainLinkTypeDiv = document.createElement("div");
chainLinkTypeDiv.className = "ff-settings-entry ff-settings-entry-small";
chainLinkTypeDiv.style.marginLeft = "20px";
const chainLinkTypeLabel = document.createElement("label");
chainLinkTypeLabel.textContent = "Chain button opens:";
chainLinkTypeLabel.className = "ff-settings-label ff-settings-label-inline";
chainLinkTypeDiv.appendChild(chainLinkTypeLabel);
const chainLinkTypeSelect = document.createElement("select");
chainLinkTypeSelect.id = "chain-link-type";
chainLinkTypeSelect.className = "ff-settings-input";
const attackOption = document.createElement("option");
attackOption.value = "attack";
attackOption.textContent = "Attack page";
chainLinkTypeSelect.appendChild(attackOption);
const profileOption = document.createElement("option");
profileOption.value = "profile";
profileOption.textContent = "Profile page";
chainLinkTypeSelect.appendChild(profileOption);
chainLinkTypeSelect.value = ffSettingsGet("chain-link-type") || "attack";
chainLinkTypeDiv.appendChild(chainLinkTypeSelect);
content.appendChild(chainLinkTypeDiv);
const chainTabTypeDiv = document.createElement("div");
chainTabTypeDiv.className = "ff-settings-entry ff-settings-entry-small";
chainTabTypeDiv.style.marginLeft = "20px";
const chainTabTypeLabel = document.createElement("label");
chainTabTypeLabel.textContent = "Open in:";
chainTabTypeLabel.className = "ff-settings-label ff-settings-label-inline";
chainTabTypeDiv.appendChild(chainTabTypeLabel);
const chainTabTypeSelect = document.createElement("select");
chainTabTypeSelect.id = "chain-tab-type";
chainTabTypeSelect.className = "ff-settings-input";
const newTabOption = document.createElement("option");
newTabOption.value = "newtab";
newTabOption.textContent = "New tab";
chainTabTypeSelect.appendChild(newTabOption);
const sameTabOption = document.createElement("option");
sameTabOption.value = "sametab";
sameTabOption.textContent = "Same tab";
chainTabTypeSelect.appendChild(sameTabOption);
chainTabTypeSelect.value = ffSettingsGet("chain-tab-type") || "newtab";
chainTabTypeDiv.appendChild(chainTabTypeSelect);
content.appendChild(chainTabTypeDiv);
// War Monitor Toggle
const warToggleDiv = document.createElement("div");
warToggleDiv.className = "ff-settings-entry ff-settings-entry-section";
const warToggle = document.createElement("input");
warToggle.type = "checkbox";
warToggle.id = "war-monitor-toggle";
warToggle.checked = ffSettingsGetToggle("war-monitor-enabled");
warToggle.className = "ff-settings-checkbox";
const warLabel = document.createElement("label");
warLabel.setAttribute("for", "war-monitor-toggle");
warLabel.textContent = "Enable War Monitor (Faction Status)";
warLabel.className = "ff-settings-label";
warLabel.style.cursor = "pointer";
warToggleDiv.appendChild(warToggle);
warToggleDiv.appendChild(warLabel);
content.appendChild(warToggleDiv);
const saveButtonDiv = document.createElement("div");
saveButtonDiv.className = "ff-settings-button-container";
const resetButton = document.createElement("button");
resetButton.textContent = "Reset to Defaults";
resetButton.className = "ff-settings-button ff-settings-button-large";
resetButton.addEventListener("click", function () {
const confirmed = confirm(
"Are you sure you want to reset all settings to their default values?",
);
if (!confirmed) return;
reset_ff_ranges();
ffSettingsSetToggle("chain-button-enabled", true);
ffSettingsSet("chain-link-type", "attack");
ffSettingsSet("chain-tab-type", "newtab");
ffSettingsSetToggle("war-monitor-enabled", true);
ffSettingsSetToggle("debug-logs", false);
document.getElementById("ff-ranges").value = "";
document.getElementById("chain-button-toggle").checked = true;
document.getElementById("chain-link-type").value = "attack";
document.getElementById("chain-tab-type").value = "newtab";
document.getElementById("war-monitor-toggle").checked = true;
document.getElementById("debug-logs").checked = false;
document.getElementById("ff-ranges").style.outline = "none";
const existingButtons = Array.from(
document.querySelectorAll("button"),
).filter(
(btn) =>
btn.textContent === "FF" &&
btn.style.position === "fixed" &&
btn.style.backgroundColor === "green",
);
existingButtons.forEach((btn) => btn.remove());
create_chain_button();
showToast("Settings reset to defaults!");
this.style.backgroundColor = "var(--ff-success-color)";
setTimeout(() => {
this.style.backgroundColor = "";
}, 1000);
});
const saveButton = document.createElement("button");
saveButton.textContent = "Save Settings";
saveButton.className = "ff-settings-button ff-settings-button-large";
saveButton.addEventListener("click", function () {
const apiKey = document.getElementById("ff-api-key").value;
const ranges = document.getElementById("ff-ranges").value;
const chainEnabled = document.getElementById(
"chain-button-toggle",
).checked;
const chainLinkType = document.getElementById("chain-link-type").value;
const chainTabType = document.getElementById("chain-tab-type").value;
const warEnabled = document.getElementById("war-monitor-toggle").checked;
const debugEnabled = document.getElementById("debug-logs").checked;
let hasErrors = false;
if (apiKey !== key) {
rD_setValue("limited_key", apiKey);
key = apiKey;
if (apiKey) {
settingsPanel.classList.remove("ff-settings-glow");
document.getElementById("ff-api-key").classList.add("ff-blur");
} else {
settingsPanel.classList.add("ff-settings-glow");
}
}
const rangesInput = document.getElementById("ff-ranges");
if (ranges === "") {
reset_ff_ranges();
rangesInput.style.outline = "none";
} else {
const parts = ranges.split(",").map((p) => p.trim());
if (parts.length !== 3) {
rangesInput.style.outline = "1px solid red";
showToast(
"FF ranges must be exactly 3 numbers separated by commas [low,high,max]",
);
hasErrors = true;
} else {
try {
const low = parseFloat(parts[0]);
const high = parseFloat(parts[1]);
const max = parseFloat(parts[2]);
if (isNaN(low) || isNaN(high) || isNaN(max)) {
rangesInput.style.outline = "1px solid red";
showToast("FF ranges must be valid numbers");
hasErrors = true;
} else if (low <= 0 || high <= 0 || max <= 0) {
rangesInput.style.outline = "1px solid red";
showToast("FF ranges must be positive numbers");
hasErrors = true;
} else if (low >= high || high >= max) {
rangesInput.style.outline = "1px solid red";
showToast(
"FF ranges must be in ascending order: low < high < max",
);
hasErrors = true;
} else {
set_ff_ranges(low, high, max);
rangesInput.style.outline = "none";
}
} catch (e) {
rangesInput.style.outline = "1px solid red";
showToast("Invalid FF ranges format");
hasErrors = true;
}
}
}
if (hasErrors) {
return;
}
const wasChainEnabled = ffSettingsGetToggle("chain-button-enabled");
const wasWarEnabled = ffSettingsGetToggle("war-monitor-enabled");
ffSettingsSetToggle("chain-button-enabled", chainEnabled);
ffSettingsSet("chain-link-type", chainLinkType);
ffSettingsSet("chain-tab-type", chainTabType);
ffSettingsSetToggle("war-monitor-enabled", warEnabled);
ffSettingsSetToggle("debug-logs", debugEnabled);
const existingButtons = Array.from(
document.querySelectorAll("button"),
).filter(
(btn) =>
btn.textContent === "FF" &&
btn.style.position === "fixed" &&
btn.style.backgroundColor === "green",
);
if (!chainEnabled) {
existingButtons.forEach((btn) => btn.remove());
} else if (chainEnabled !== wasChainEnabled) {
if (existingButtons.length === 0) {
create_chain_button();
}
} else {
existingButtons.forEach((btn) => btn.remove());
create_chain_button();
}
if (warEnabled !== wasWarEnabled) {
if (!warEnabled) {
window.dispatchEvent(new Event("FFScouterV2DisableWarMonitor"));
} else {
location.reload();
}
}
showToast("Settings saved successfully!");
this.style.backgroundColor = "var(--ff-success-color)";
setTimeout(() => {
this.style.backgroundColor = "";
}, 1000);
});
saveButtonDiv.appendChild(resetButton);
saveButtonDiv.appendChild(saveButton);
content.appendChild(saveButtonDiv);
const cacheLabel = document.createElement("p");
cacheLabel.textContent = "Cache management:";
cacheLabel.className = "ff-settings-section-header";
content.appendChild(cacheLabel);
const cacheButtonDiv = document.createElement("div");
cacheButtonDiv.className = "ff-settings-button-container";
const clearCacheBtn = document.createElement("button");
clearCacheBtn.textContent = "Clear FF Cache";
clearCacheBtn.className = "ff-settings-button";
clearCacheBtn.addEventListener("click", function () {
const confirmed = confirm(
"Are you sure you want to clear all FF Scouter cache?",
);
if (!confirmed) return;
let count = 0;
const keysToRemove = [];
for (const key of rD_listValues()) {
if (
key.startsWith("ffscouterv2-") &&
!key.includes("limited_key") &&
!key.includes("ranges")
) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
rD_deleteValue(key);
count++;
}
showToast(`Cleared ${count} cached items`);
});
cacheButtonDiv.appendChild(clearCacheBtn);
content.appendChild(cacheButtonDiv);
const debugLabel = document.createElement("p");
debugLabel.textContent = "Debug settings:";
debugLabel.className = "ff-settings-section-header";
content.appendChild(debugLabel);
const debugToggleDiv = document.createElement("div");
debugToggleDiv.className = "ff-settings-entry ff-settings-entry-small";
const debugToggle = document.createElement("input");
debugToggle.type = "checkbox";
debugToggle.id = "debug-logs";
debugToggle.checked = ffSettingsGetToggle("debug-logs");
debugToggle.className = "ff-settings-checkbox";
const debugToggleLabel = document.createElement("label");
debugToggleLabel.setAttribute("for", "debug-logs");
debugToggleLabel.textContent = "Enable debug logging";
debugToggleLabel.className = "ff-settings-label";
debugToggleLabel.style.cursor = "pointer";
debugToggleDiv.appendChild(debugToggle);
debugToggleDiv.appendChild(debugToggleLabel);
content.appendChild(debugToggleDiv);
settingsPanel.appendChild(content);
profileWrapper.parentNode.insertBefore(
settingsPanel,
profileWrapper.nextSibling,
);
console.log("[FF Scouter V2] Settings panel created successfully");
}
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";
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}`;
}
console.log("[FF Scouter V2] Toast: ", message);
toast.appendChild(msg);
toast.appendChild(closeBtn);
document.body.appendChild(toast);
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 500);
}
}, 4000);
}
create_chain_button();
update_ff_targets();
getLocalUserId().then((userId) => {
if (userId) {
currentUserId = userId;
console.log(
`[FF Scouter V2] Current user ID initialized: ${currentUserId}`,
);
createSettingsPanel();
const profileObserver = new MutationObserver(() => {
const pageId = window.location.href.match(/XID=(\d+)/)?.[1];
if (
pageId === currentUserId &&
window.location.pathname === "/profiles.php"
) {
createSettingsPanel();
}
});
profileObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
});
}