您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows the expected Fair Fight score against targets and faction war status
// ==UserScript== // @name FF Scouter V2 xentac edition // @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 // ==/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, }); } }); }