FF Scouter V2

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

  1. // ==UserScript==
  2. // @name FF Scouter V2
  3. // @namespace Violentmonkey Scripts
  4. // @match https://www.torn.com/*
  5. // @version 2.46
  6. // @author rDacted, Weav3r, xentac
  7. // @description Shows the expected Fair Fight score against targets and faction war status
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_deleteValue
  12. // @grant GM_registerMenuCommand
  13. // @grant GM_addStyle
  14. // @connect ffscouter.com
  15. // ==/UserScript==
  16.  
  17. const FF_VERSION = "2.46";
  18. const API_INTERVAL = 30000;
  19. const memberCountdowns = {};
  20. let apiCallInProgressCount = 0;
  21.  
  22. let singleton = document.getElementById("ff-scouter-run-once");
  23. if (!singleton) {
  24. console.log(`[FF Scouter V2] FF Scouter version ${FF_VERSION} starting`);
  25. GM_addStyle(`
  26. .ff-scouter-indicator {
  27. position: relative;
  28. display: block;
  29. padding: 0;
  30. }
  31.  
  32. .ff-scouter-vertical-line-low-upper,
  33. .ff-scouter-vertical-line-low-lower,
  34. .ff-scouter-vertical-line-high-upper,
  35. .ff-scouter-vertical-line-high-lower {
  36. content: '';
  37. position: absolute;
  38. width: 2px;
  39. height: 30%;
  40. background-color: black;
  41. margin-left: -1px;
  42. }
  43.  
  44. .ff-scouter-vertical-line-low-upper {
  45. top: 0;
  46. left: calc(var(--arrow-width) / 2 + 33 * (100% - var(--arrow-width)) / 100);
  47. }
  48.  
  49. .ff-scouter-vertical-line-low-lower {
  50. bottom: 0;
  51. left: calc(var(--arrow-width) / 2 + 33 * (100% - var(--arrow-width)) / 100);
  52. }
  53.  
  54. .ff-scouter-vertical-line-high-upper {
  55. top: 0;
  56. left: calc(var(--arrow-width) / 2 + 66 * (100% - var(--arrow-width)) / 100);
  57. }
  58.  
  59. .ff-scouter-vertical-line-high-lower {
  60. bottom: 0;
  61. left: calc(var(--arrow-width) / 2 + 66 * (100% - var(--arrow-width)) / 100);
  62. }
  63.  
  64. .ff-scouter-arrow {
  65. position: absolute;
  66. transform: translate(-50%, -50%);
  67. padding: 0;
  68. top: 0;
  69. left: calc(var(--arrow-width) / 2 + var(--band-percent) * (100% - var(--arrow-width)) / 100);
  70. width: var(--arrow-width);
  71. object-fit: cover;
  72. pointer-events: none;
  73. }
  74.  
  75. .last-action-row {
  76. font-size: 11px;
  77. color: inherit;
  78. font-style: normal;
  79. font-weight: normal;
  80. text-align: center;
  81. margin-left: 8px;
  82. margin-bottom: 2px;
  83. margin-top: -2px;
  84. display: block;
  85. }
  86. .travel-status {
  87. display: flex;
  88. align-items: center;
  89. justify-content: flex-end;
  90. gap: 2px;
  91. min-width: 0;
  92. overflow: hidden;
  93. }
  94. .torn-symbol {
  95. width: 16px;
  96. height: 16px;
  97. fill: currentColor;
  98. vertical-align: middle;
  99. flex-shrink: 0;
  100. }
  101. .plane-svg {
  102. width: 14px;
  103. height: 14px;
  104. fill: currentColor;
  105. vertical-align: middle;
  106. flex-shrink: 0;
  107. }
  108. .plane-svg.returning {
  109. transform: scaleX(-1);
  110. }
  111. .country-abbr {
  112. overflow: hidden;
  113. text-overflow: ellipsis;
  114. white-space: nowrap;
  115. min-width: 0;
  116. flex: 0 1 auto;
  117. vertical-align: bottom;
  118. }
  119. `);
  120.  
  121. var BASE_URL = "https://ffscouter.com";
  122. var BLUE_ARROW =
  123. "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/blue-arrow.svg";
  124. var GREEN_ARROW =
  125. "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/green-arrow.svg";
  126. var RED_ARROW =
  127. "https://raw.githubusercontent.com/rDacted2/fair_fight_scouter/main/images/red-arrow.svg";
  128.  
  129. var rD_xmlhttpRequest;
  130. var rD_setValue;
  131. var rD_getValue;
  132. var rD_deleteValue;
  133. var rD_registerMenuCommand;
  134.  
  135. // DO NOT CHANGE THIS
  136. // DO NOT CHANGE THIS
  137. var apikey = "###PDA-APIKEY###";
  138. // DO NOT CHANGE THIS
  139. // DO NOT CHANGE THIS
  140. if (apikey[0] != "#") {
  141. console.log("[FF Scouter V2] Adding modifications to support TornPDA");
  142. rD_xmlhttpRequest = function (details) {
  143. console.log("[FF Scouter V2] Attempt to make http request");
  144. if (details.method.toLowerCase() == "get") {
  145. return PDA_httpGet(details.url)
  146. .then(details.onload)
  147. .catch(
  148. details.onerror ?? ((e) => console.error("[FF Scouter V2] ", e)),
  149. );
  150. } else if (details.method.toLowerCase() == "post") {
  151. return PDA_httpPost(
  152. details.url,
  153. details.headers ?? {},
  154. details.body ?? details.data ?? "",
  155. )
  156. .then(details.onload)
  157. .catch(
  158. details.onerror ?? ((e) => console.error("[FF Scouter V2] ", e)),
  159. );
  160. } else {
  161. console.log("[FF Scouter V2] What is this? " + details.method);
  162. }
  163. };
  164. rD_setValue = function (name, value) {
  165. console.log("[FF Scouter V2] Attempted to set " + name);
  166. return localStorage.setItem(name, value);
  167. };
  168. rD_getValue = function (name, defaultValue) {
  169. var value = localStorage.getItem(name) ?? defaultValue;
  170. return value;
  171. };
  172. rD_deleteValue = function (name) {
  173. console.log("[FF Scouter V2] Attempted to delete " + name);
  174. return localStorage.removeItem(name);
  175. };
  176. rD_registerMenuCommand = function () {
  177. console.log("[FF Scouter V2] Disabling GM_registerMenuCommand");
  178. };
  179. rD_setValue("limited_key", apikey);
  180. } else {
  181. rD_xmlhttpRequest = GM_xmlhttpRequest;
  182. rD_setValue = GM_setValue;
  183. rD_getValue = GM_getValue;
  184. rD_deleteValue = GM_deleteValue;
  185. rD_registerMenuCommand = GM_registerMenuCommand;
  186. }
  187.  
  188. var key = rD_getValue("limited_key", null);
  189. var info_line = null;
  190.  
  191. rD_registerMenuCommand("Enter Limited API Key", () => {
  192. let userInput = prompt(
  193. "Enter Limited API Key",
  194. rD_getValue("limited_key", ""),
  195. );
  196. if (userInput !== null) {
  197. rD_setValue("limited_key", userInput);
  198. // Reload page
  199. window.location.reload();
  200. }
  201. });
  202.  
  203. function create_text_location() {
  204. info_line = document.createElement("div");
  205. info_line.id = "ff-scouter-run-once";
  206. info_line.style.display = "block";
  207. info_line.style.clear = "both";
  208. info_line.style.margin = "5px 0";
  209. info_line.addEventListener("click", () => {
  210. if (key === null) {
  211. const limited_key = prompt(
  212. "Enter Limited API Key",
  213. rD_getValue("limited_key", ""),
  214. );
  215. if (limited_key) {
  216. rD_setValue("limited_key", limited_key);
  217. key = limited_key;
  218. window.location.reload();
  219. }
  220. }
  221. });
  222.  
  223. var h4 = $("h4")[0];
  224. if (h4.textContent === "Attacking") {
  225. h4.parentNode.parentNode.after(info_line);
  226. } else {
  227. const linksTopWrap = h4.parentNode.querySelector(".links-top-wrap");
  228. if (linksTopWrap) {
  229. linksTopWrap.parentNode.insertBefore(
  230. info_line,
  231. linksTopWrap.nextSibling,
  232. );
  233. } else {
  234. h4.after(info_line);
  235. }
  236. }
  237.  
  238. return info_line;
  239. }
  240.  
  241. function set_message(message, error = false) {
  242. while (info_line.firstChild) {
  243. info_line.removeChild(info_line.firstChild);
  244. }
  245.  
  246. const textNode = document.createTextNode(message);
  247. if (error) {
  248. info_line.style.color = "red";
  249. } else {
  250. info_line.style.color = "";
  251. }
  252. info_line.appendChild(textNode);
  253. }
  254.  
  255. function update_ff_cache(player_ids, callback) {
  256. if (!key) {
  257. return;
  258. }
  259.  
  260. player_ids = [...new Set(player_ids)];
  261.  
  262. var unknown_player_ids = get_cache_misses(player_ids);
  263.  
  264. if (unknown_player_ids.length > 0) {
  265. console.log(
  266. `[FF Scouter V2] Refreshing cache for ${unknown_player_ids.length} ids`,
  267. );
  268.  
  269. var player_id_list = unknown_player_ids.join(",");
  270. const url = `${BASE_URL}/api/v1/get-stats?key=${key}&targets=${player_id_list}`;
  271.  
  272. rD_xmlhttpRequest({
  273. method: "GET",
  274. url: url,
  275. onload: function (response) {
  276. if (response.status == 200) {
  277. var ff_response = JSON.parse(response.responseText);
  278. if (ff_response && ff_response.error) {
  279. showToast(ff_response.error);
  280. return;
  281. }
  282. var one_hour = 60 * 60 * 1000;
  283. var expiry = Date.now() + one_hour;
  284. ff_response.forEach((result) => {
  285. if (result && result.player_id) {
  286. if (result.fair_fight === null) {
  287. let cacheObj = {
  288. no_data: true,
  289. expiry: expiry,
  290. };
  291. rD_setValue("" + result.player_id, JSON.stringify(cacheObj));
  292. } else {
  293. let cacheObj = {
  294. value: result.fair_fight,
  295. last_updated: result.last_updated,
  296. expiry: expiry,
  297. bs_estimate: result.bs_estimate,
  298. bs_estimate_human: result.bs_estimate_human,
  299. };
  300. rD_setValue("" + result.player_id, JSON.stringify(cacheObj));
  301. }
  302. }
  303. });
  304. callback(player_ids);
  305. } else {
  306. try {
  307. var err = JSON.parse(response.responseText);
  308. if (err && err.error) {
  309. showToast(err.error);
  310. } else {
  311. showToast("API request failed.");
  312. }
  313. } catch {
  314. showToast("API request failed.");
  315. }
  316. }
  317. },
  318. onerror: function (e) {
  319. console.error("[FF Scouter V2] **** error ", e);
  320. },
  321. onabort: function (e) {
  322. console.error("[FF Scouter V2] **** abort ", e);
  323. },
  324. ontimeout: function (e) {
  325. console.error("[FF Scouter V2] **** timeout ", e);
  326. },
  327. });
  328. } else {
  329. callback(player_ids);
  330. }
  331. }
  332.  
  333. function display_fair_fight(target_id, player_id) {
  334. const response = get_cached_value(target_id);
  335. if (response) {
  336. set_fair_fight(response, player_id);
  337. }
  338. }
  339.  
  340. function get_ff_string(ff_response) {
  341. const ff = ff_response.value.toFixed(2);
  342.  
  343. const now = Date.now() / 1000;
  344. const age = now - ff_response.last_updated;
  345.  
  346. var suffix = "";
  347. if (age > 14 * 24 * 60 * 60) {
  348. suffix = "?";
  349. }
  350.  
  351. return `${ff}${suffix}`;
  352. }
  353.  
  354. function get_difficulty_text(ff) {
  355. if (ff <= 1) {
  356. return "Extremely easy";
  357. } else if (ff <= 2) {
  358. return "Easy";
  359. } else if (ff <= 3.5) {
  360. return "Moderately difficult";
  361. } else if (ff <= 4.5) {
  362. return "Difficult";
  363. } else {
  364. return "May be impossible";
  365. }
  366. }
  367.  
  368. function get_detailed_message(ff_response, player_id) {
  369. if (ff_response.no_data || !ff_response.value) {
  370. 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>`;
  371. }
  372. const ff_string = get_ff_string(ff_response);
  373. const difficulty = get_difficulty_text(ff_response.value);
  374.  
  375. const now = Date.now() / 1000;
  376. const age = now - ff_response.last_updated;
  377.  
  378. var fresh = "";
  379.  
  380. if (age < 24 * 60 * 60) {
  381. // Pass
  382. } else if (age < 31 * 24 * 60 * 60) {
  383. var days = Math.round(age / (24 * 60 * 60));
  384. if (days == 1) {
  385. fresh = "(1 day old)";
  386. } else {
  387. fresh = `(${days} days old)`;
  388. }
  389. } else if (age < 365 * 24 * 60 * 60) {
  390. var months = Math.round(age / (31 * 24 * 60 * 60));
  391. if (months == 1) {
  392. fresh = "(1 month old)";
  393. } else {
  394. fresh = `(${months} months old)`;
  395. }
  396. } else {
  397. var years = Math.round(age / (365 * 24 * 60 * 60));
  398. if (years == 1) {
  399. fresh = "(1 year old)";
  400. } else {
  401. fresh = `(${years} years old)`;
  402. }
  403. }
  404.  
  405. const background_colour = get_ff_colour(ff_response.value);
  406. const text_colour = get_contrast_color(background_colour);
  407.  
  408. let statDetails = "";
  409. if (ff_response.bs_estimate_human) {
  410. statDetails = `<span style=\"font-size: 11px; font-weight: normal; margin-left: 8px; vertical-align: middle; color: #cccccc; font-style: italic;\">Est. Stats: <span>${ff_response.bs_estimate_human}</span></span>`;
  411. }
  412.  
  413. 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}`;
  414. }
  415.  
  416. function get_ff_string_short(ff_response, player_id) {
  417. const ff = ff_response.value.toFixed(2);
  418.  
  419. const now = Date.now() / 1000;
  420. const age = now - ff_response.last_updated;
  421.  
  422. if (ff > 9) {
  423. return `high`;
  424. }
  425.  
  426. var suffix = "";
  427. if (age > 14 * 24 * 60 * 60) {
  428. suffix = "?";
  429. }
  430.  
  431. return `${ff}${suffix}`;
  432. }
  433.  
  434. function set_fair_fight(ff_response, player_id) {
  435. const detailed_message = get_detailed_message(ff_response, player_id);
  436. info_line.innerHTML = detailed_message;
  437. }
  438.  
  439. function get_members() {
  440. var player_ids = [];
  441. $(".table-body > .table-row").each(function () {
  442. if (!$(this).find(".fallen").length) {
  443. if (!$(this).find(".fedded").length) {
  444. $(this)
  445. .find(".member")
  446. .each(function (index, value) {
  447. var url = value.querySelectorAll('a[href^="/profiles"]')[0].href;
  448. var player_id = url.match(/.*XID=(?<player_id>\d+)/).groups
  449. .player_id;
  450. player_ids.push(parseInt(player_id));
  451. });
  452. }
  453. }
  454. });
  455.  
  456. return player_ids;
  457. }
  458.  
  459. function rgbToHex(r, g, b) {
  460. return (
  461. "#" +
  462. ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()
  463. ); // Convert to hex and return
  464. }
  465.  
  466. function get_ff_colour(value) {
  467. let r, g, b;
  468.  
  469. // Transition from
  470. // blue - #2828c6
  471. // to
  472. // green - #28c628
  473. // to
  474. // red - #c62828
  475. if (value <= 1) {
  476. // Blue
  477. r = 0x28;
  478. g = 0x28;
  479. b = 0xc6;
  480. } else if (value <= 3) {
  481. // Transition from blue to green
  482. const t = (value - 1) / 2; // Normalize to range [0, 1]
  483. r = 0x28;
  484. g = Math.round(0x28 + (0xc6 - 0x28) * t);
  485. b = Math.round(0xc6 - (0xc6 - 0x28) * t);
  486. } else if (value <= 5) {
  487. // Transition from green to red
  488. const t = (value - 3) / 2; // Normalize to range [0, 1]
  489. r = Math.round(0x28 + (0xc6 - 0x28) * t);
  490. g = Math.round(0xc6 - (0xc6 - 0x28) * t);
  491. b = 0x28;
  492. } else {
  493. // Red
  494. r = 0xc6;
  495. g = 0x28;
  496. b = 0x28;
  497. }
  498.  
  499. return rgbToHex(r, g, b); // Return hex value
  500. }
  501.  
  502. function get_contrast_color(hex) {
  503. // Convert hex to RGB
  504. const r = parseInt(hex.slice(1, 3), 16);
  505. const g = parseInt(hex.slice(3, 5), 16);
  506. const b = parseInt(hex.slice(5, 7), 16);
  507.  
  508. // Calculate brightness
  509. const brightness = r * 0.299 + g * 0.587 + b * 0.114;
  510. return brightness > 126 ? "black" : "white"; // Return black or white based on brightness
  511. }
  512.  
  513. function get_cached_value(player_id) {
  514. var cached_ff_response = rD_getValue("" + player_id, null);
  515. try {
  516. cached_ff_response = JSON.parse(cached_ff_response);
  517. } catch {
  518. cached_ff_response = null;
  519. }
  520.  
  521. if (
  522. cached_ff_response &&
  523. cached_ff_response.value &&
  524. !cached_ff_response.no_data &&
  525. cached_ff_response.expiry > Date.now()
  526. ) {
  527. return cached_ff_response;
  528. }
  529. return null;
  530. }
  531.  
  532. function apply_fair_fight_info(_) {
  533. var header_li = document.createElement("li");
  534. header_li.tabIndex = "0";
  535. header_li.classList.add("table-cell");
  536. header_li.classList.add("lvl");
  537. header_li.classList.add("torn-divider");
  538. header_li.classList.add("divider-vertical");
  539. header_li.classList.add("c-pointer");
  540. header_li.appendChild(document.createTextNode("FF"));
  541.  
  542. $(".table-header > .lvl")[0].after(header_li);
  543.  
  544. $(".table-body > .table-row > .member").each(function (_, value) {
  545. var url = value.querySelectorAll('a[href^="/profiles"]')[0].href;
  546. var player_id = url.match(/.*XID=(?<player_id>\d+)/).groups.player_id;
  547.  
  548. var fair_fight_div = document.createElement("div");
  549. fair_fight_div.classList.add("table-cell");
  550. fair_fight_div.classList.add("lvl");
  551.  
  552. const cached = get_cached_value(player_id);
  553. if (cached) {
  554. const ff = cached.value;
  555. const ff_string = get_ff_string_short(cached, player_id);
  556.  
  557. const background_colour = get_ff_colour(ff);
  558. const text_colour = get_contrast_color(background_colour);
  559. fair_fight_div.style.backgroundColor = background_colour;
  560. fair_fight_div.style.color = text_colour;
  561. fair_fight_div.style.fontWeight = "bold";
  562. fair_fight_div.innerHTML = ff_string;
  563. }
  564.  
  565. value.nextSibling.after(fair_fight_div);
  566. });
  567. }
  568.  
  569. function get_cache_misses(player_ids) {
  570. var unknown_player_ids = [];
  571. for (const player_id of player_ids) {
  572. if (!get_cached_value(player_id)) {
  573. unknown_player_ids.push(player_id);
  574. }
  575. }
  576.  
  577. return unknown_player_ids;
  578. }
  579.  
  580. create_text_location();
  581.  
  582. const match1 = window.location.href.match(
  583. /https:\/\/www.torn.com\/profiles.php\?XID=(?<target_id>\d+)/,
  584. );
  585. const match2 = window.location.href.match(
  586. /https:\/\/www.torn.com\/loader.php\?sid=attack&user2ID=(?<target_id>\d+)/,
  587. );
  588. const match = match1 ?? match2;
  589. if (match) {
  590. // We're on a profile page or an attack page - get the fair fight score
  591. var target_id = match.groups.target_id;
  592. update_ff_cache([target_id], function (target_ids) {
  593. display_fair_fight(target_ids[0], target_id);
  594. });
  595.  
  596. if (!key) {
  597. set_message("Limited API key needed - click to add");
  598. }
  599. } else if (
  600. window.location.href.startsWith("https://www.torn.com/factions.php")
  601. ) {
  602. const torn_observer = new MutationObserver(function () {
  603. // Find the member table - add a column if it doesn't already have one, for FF scores
  604. var members_list = $(".members-list")[0];
  605. if (members_list) {
  606. torn_observer.disconnect();
  607.  
  608. var player_ids = get_members();
  609. update_ff_cache(player_ids, apply_fair_fight_info);
  610. }
  611. });
  612.  
  613. torn_observer.observe(document, {
  614. attributes: false,
  615. childList: true,
  616. characterData: false,
  617. subtree: true,
  618. });
  619.  
  620. if (!key) {
  621. set_message("Limited API key needed - click to add");
  622. }
  623. } else {
  624. // console.log("Did not match against " + window.location.href);
  625. }
  626.  
  627. function get_player_id_in_element(element) {
  628. const match = element.parentElement?.href?.match(/.*XID=(?<target_id>\d+)/);
  629. if (match) {
  630. return match.groups.target_id;
  631. }
  632.  
  633. const anchors = element.getElementsByTagName("a");
  634.  
  635. for (const anchor of anchors) {
  636. const match = anchor.href.match(/.*XID=(?<target_id>\d+)/);
  637. if (match) {
  638. return match.groups.target_id;
  639. }
  640. }
  641.  
  642. if (element.nodeName.toLowerCase() === "a") {
  643. const match = element.href.match(/.*XID=(?<target_id>\d+)/);
  644. if (match) {
  645. return match.groups.target_id;
  646. }
  647. }
  648.  
  649. return null;
  650. }
  651.  
  652. function ff_to_percent(ff) {
  653. // There are 3 key areas, low, medium, high
  654. // Low is 1-2
  655. // Medium is 2-4
  656. // High is 4+
  657. // If we clip high at 8 then the math becomes easy
  658. // The percent is 0-33% 33-66% 66%-100%
  659. const low_ff = 2;
  660. const high_ff = 4;
  661. const low_mid_percent = 33;
  662. const mid_high_percent = 66;
  663. ff = Math.min(ff, 8);
  664. var percent;
  665. if (ff < low_ff) {
  666. percent = ((ff - 1) / (low_ff - 1)) * low_mid_percent;
  667. } else if (ff < high_ff) {
  668. percent =
  669. ((ff - low_ff) / (high_ff - low_ff)) *
  670. (mid_high_percent - low_mid_percent) +
  671. low_mid_percent;
  672. } else {
  673. percent =
  674. ((ff - high_ff) / (8 - high_ff)) * (100 - mid_high_percent) +
  675. mid_high_percent;
  676. }
  677.  
  678. return percent;
  679. }
  680.  
  681. function show_cached_values(elements) {
  682. for (const [player_id, element] of elements) {
  683. element.classList.add("ff-scouter-indicator");
  684. if (!element.classList.contains("indicator-lines")) {
  685. element.classList.add("indicator-lines");
  686. element.style.setProperty("--arrow-width", "20px");
  687.  
  688. // Ugly - does removing this break anything?
  689. element.classList.remove("small");
  690. element.classList.remove("big");
  691.  
  692. //$(element).append($("<div>", { class: "ff-scouter-vertical-line-low-upper" }));
  693. //$(element).append($("<div>", { class: "ff-scouter-vertical-line-low-lower" }));
  694. //$(element).append($("<div>", { class: "ff-scouter-vertical-line-high-upper" }));
  695. //$(element).append($("<div>", { class: "ff-scouter-vertical-line-high-lower" }));
  696. }
  697.  
  698. const cached = get_cached_value(player_id);
  699. if (cached) {
  700. const percent = ff_to_percent(cached.value);
  701. element.style.setProperty("--band-percent", percent);
  702.  
  703. $(element).find(".ff-scouter-arrow").remove();
  704.  
  705. var arrow;
  706. if (percent < 33) {
  707. arrow = BLUE_ARROW;
  708. } else if (percent < 66) {
  709. arrow = GREEN_ARROW;
  710. } else {
  711. arrow = RED_ARROW;
  712. }
  713. const img = $("<img>", {
  714. src: arrow,
  715. class: "ff-scouter-arrow",
  716. });
  717. $(element).append(img);
  718. }
  719. }
  720. }
  721.  
  722. async function apply_ff_gauge(elements) {
  723. // Remove elements which already have the class
  724. elements = elements.filter(
  725. (e) => !e.classList.contains("ff-scouter-indicator"),
  726. );
  727. // Convert elements to a list of tuples
  728. elements = elements.map((e) => {
  729. const player_id = get_player_id_in_element(e);
  730. return [player_id, e];
  731. });
  732. // Remove any elements that don't have an id
  733. elements = elements.filter((e) => e[0]);
  734.  
  735. if (elements.length > 0) {
  736. // Display cached values immediately
  737. // This is also important to ensure we only iterate the list once
  738. // Then update
  739. // Then re-display after the update
  740. show_cached_values(elements);
  741. const player_ids = elements.map((e) => e[0]);
  742. update_ff_cache(player_ids, () => {
  743. show_cached_values(elements);
  744. });
  745. }
  746. }
  747.  
  748. async function apply_to_mini_profile(mini) {
  749. // Get the user id, and the details
  750. // Then in profile-container.description append a new span with the text. Win
  751. const player_id = get_player_id_in_element(mini);
  752. if (player_id) {
  753. const response = get_cached_value(player_id);
  754. if (response) {
  755. // Remove any existing elements
  756. $(mini).find(".ff-scouter-mini-ff").remove();
  757.  
  758. // Minimal, text-only Fair Fight string for mini-profiles
  759. const ff_string = get_ff_string(response);
  760. const difficulty = get_difficulty_text(response.value);
  761. const now = Date.now() / 1000;
  762. const age = now - response.last_updated;
  763. let fresh = "";
  764. if (age < 24 * 60 * 60) {
  765. // Pass
  766. } else if (age < 31 * 24 * 60 * 60) {
  767. var days = Math.round(age / (24 * 60 * 60));
  768. fresh = days === 1 ? "(1 day old)" : `(${days} days old)`;
  769. } else if (age < 365 * 24 * 60 * 60) {
  770. var months = Math.round(age / (31 * 24 * 60 * 60));
  771. fresh = months === 1 ? "(1 month old)" : `(${months} months old)`;
  772. } else {
  773. var years = Math.round(age / (365 * 24 * 60 * 60));
  774. fresh = years === 1 ? "(1 year old)" : `(${years} years old)`;
  775. }
  776. const message = `FF ${ff_string} (${difficulty}) ${fresh}`;
  777.  
  778. const description = $(mini).find(".description");
  779. const desc = $("<span></span>", {
  780. class: "ff-scouter-mini-ff",
  781. });
  782. desc.text(message);
  783. $(description).append(desc);
  784. }
  785. }
  786. }
  787.  
  788. const ff_gauge_observer = new MutationObserver(async function () {
  789. var honor_bars = $(".honor-text-wrap").toArray();
  790. if (honor_bars.length > 0) {
  791. await apply_ff_gauge($(".honor-text-wrap").toArray());
  792. } else {
  793. if (
  794. window.location.href.startsWith("https://www.torn.com/factions.php")
  795. ) {
  796. await apply_ff_gauge($(".member").toArray());
  797. } else if (
  798. window.location.href.startsWith("https://www.torn.com/companies.php")
  799. ) {
  800. await apply_ff_gauge($(".employee").toArray());
  801. } else if (
  802. window.location.href.startsWith("https://www.torn.com/joblist.php")
  803. ) {
  804. await apply_ff_gauge($(".employee").toArray());
  805. } else if (
  806. window.location.href.startsWith("https://www.torn.com/messages.php")
  807. ) {
  808. await apply_ff_gauge($(".name").toArray());
  809. } else if (
  810. window.location.href.startsWith("https://www.torn.com/index.php")
  811. ) {
  812. await apply_ff_gauge($(".name").toArray());
  813. } else if (
  814. window.location.href.startsWith("https://www.torn.com/hospitalview.php")
  815. ) {
  816. await apply_ff_gauge($(".name").toArray());
  817. } else if (
  818. window.location.href.startsWith(
  819. "https://www.torn.com/page.php?sid=UserList",
  820. )
  821. ) {
  822. await apply_ff_gauge($(".name").toArray());
  823. } else if (
  824. window.location.href.startsWith("https://www.torn.com/bounties.php")
  825. ) {
  826. await apply_ff_gauge($(".target").toArray());
  827. await apply_ff_gauge($(".listed").toArray());
  828. } else if (
  829. window.location.href.startsWith("https://www.torn.com/forums.php")
  830. ) {
  831. await apply_ff_gauge($(".last-poster").toArray());
  832. await apply_ff_gauge($(".starter").toArray());
  833. await apply_ff_gauge($(".last-post").toArray());
  834. await apply_ff_gauge($(".poster").toArray());
  835. } else if (window.location.href.includes("page.php?sid=hof")) {
  836. await apply_ff_gauge($('[class^="userInfoBox__"]').toArray());
  837. }
  838. }
  839.  
  840. var mini_profiles = $(
  841. '[class^="profile-mini-_userProfileWrapper_"]',
  842. ).toArray();
  843. if (mini_profiles.length > 0) {
  844. for (const mini of mini_profiles) {
  845. if (!mini.classList.contains("ff-processed")) {
  846. mini.classList.add("ff-processed");
  847.  
  848. const player_id = get_player_id_in_element(mini);
  849. apply_to_mini_profile(mini);
  850. update_ff_cache([player_id], () => {
  851. apply_to_mini_profile(mini);
  852. });
  853. }
  854. }
  855. }
  856. });
  857.  
  858. ff_gauge_observer.observe(document, {
  859. attributes: false,
  860. childList: true,
  861. characterData: false,
  862. subtree: true,
  863. });
  864.  
  865. function abbreviateCountry(name) {
  866. if (!name) return "";
  867. if (name.trim().toLowerCase() === "switzerland") return "Switz";
  868. const words = name.trim().split(/\s+/);
  869. if (words.length === 1) return words[0];
  870. return words.map((w) => w[0].toUpperCase()).join("");
  871. }
  872.  
  873. function formatTime(ms) {
  874. let totalSeconds = Math.max(0, Math.floor(ms / 1000));
  875. let hours = String(Math.floor(totalSeconds / 3600)).padStart(2, "0");
  876. let minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(
  877. 2,
  878. "0",
  879. );
  880. let seconds = String(totalSeconds % 60).padStart(2, "0");
  881. return `${hours}:${minutes}:${seconds}`;
  882. }
  883.  
  884. function fetchFactionData(factionID) {
  885. const url = `https://api.torn.com/v2/faction/${factionID}/members?striptags=true&key=${key}`;
  886. return fetch(url).then((response) => response.json());
  887. }
  888.  
  889. function updateMemberStatus(li, member) {
  890. if (!member || !member.status) return;
  891.  
  892. let statusEl = li.querySelector(".status");
  893. if (!statusEl) return;
  894.  
  895. let lastActionRow = li.querySelector(".last-action-row");
  896. let lastActionText = member.last_action?.relative || "";
  897. if (lastActionRow) {
  898. lastActionRow.textContent = `Last Action: ${lastActionText}`;
  899. } else {
  900. lastActionRow = document.createElement("div");
  901. lastActionRow.className = "last-action-row";
  902. lastActionRow.textContent = `Last Action: ${lastActionText}`;
  903. let lastDiv = Array.from(li.children)
  904. .reverse()
  905. .find((el) => el.tagName === "DIV");
  906. if (lastDiv?.nextSibling) {
  907. li.insertBefore(lastActionRow, lastDiv.nextSibling);
  908. } else {
  909. li.appendChild(lastActionRow);
  910. }
  911. }
  912.  
  913. // Handle status changes
  914. if (member.status.state === "Okay") {
  915. if (statusEl.dataset.originalHtml) {
  916. statusEl.innerHTML = statusEl.dataset.originalHtml;
  917. delete statusEl.dataset.originalHtml;
  918. }
  919. statusEl.textContent = "Okay";
  920. } else if (member.status.state === "Traveling") {
  921. if (!statusEl.dataset.originalHtml) {
  922. statusEl.dataset.originalHtml = statusEl.innerHTML;
  923. }
  924.  
  925. let description = member.status.description || "";
  926. let location = "";
  927. let isReturning = false;
  928.  
  929. if (description.includes("Returning to Torn from ")) {
  930. location = description.replace("Returning to Torn from ", "");
  931. isReturning = true;
  932. } else if (description.includes("Traveling to ")) {
  933. location = description.replace("Traveling to ", "");
  934. }
  935.  
  936. let abbr = abbreviateCountry(location);
  937. const planeSvg = `<svg class="plane-svg ${isReturning ? "returning" : ""}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
  938. <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"/>
  939. </svg>`;
  940. const tornSymbol = `<svg class="torn-symbol" viewBox="0 0 24 24">
  941. <circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
  942. <text x="12" y="16" text-anchor="middle" font-family="Arial" font-weight="bold" font-size="14" fill="currentColor">T</text>
  943. </svg>`;
  944. statusEl.innerHTML = `<span class="travel-status">${tornSymbol}${planeSvg}<span class="country-abbr">${abbr}</span></span>`;
  945. } else if (member.status.state === "Abroad") {
  946. if (!statusEl.dataset.originalHtml) {
  947. statusEl.dataset.originalHtml = statusEl.innerHTML;
  948. }
  949. let description = member.status.description || "";
  950. if (description.startsWith("In ")) {
  951. let location = description.replace("In ", "");
  952. let abbr = abbreviateCountry(location);
  953. statusEl.textContent = `in ${abbr}`;
  954. }
  955. }
  956.  
  957. // Update countdown
  958. if (member.status.until && parseInt(member.status.until, 10) > 0) {
  959. memberCountdowns[member.id] = parseInt(member.status.until, 10);
  960. } else {
  961. delete memberCountdowns[member.id];
  962. }
  963. }
  964.  
  965. function updateFactionStatuses(factionID, container) {
  966. apiCallInProgressCount++;
  967. fetchFactionData(factionID)
  968. .then((data) => {
  969. if (!Array.isArray(data.members)) {
  970. console.warn(
  971. `[FF Scouter V2] No members array for faction ${factionID}`,
  972. );
  973. return;
  974. }
  975.  
  976. const memberMap = {};
  977. data.members.forEach((member) => {
  978. memberMap[member.id] = member;
  979. });
  980.  
  981. container.querySelectorAll("li").forEach((li) => {
  982. let profileLink = li.querySelector('a[href*="profiles.php?XID="]');
  983. if (!profileLink) return;
  984. let match = profileLink.href.match(/XID=(\d+)/);
  985. if (!match) return;
  986. let userID = match[1];
  987. updateMemberStatus(li, memberMap[userID]);
  988. });
  989. })
  990. .catch((err) => {
  991. console.error(
  992. "[FF Scouter V2] Error fetching faction data for faction",
  993. factionID,
  994. err,
  995. );
  996. })
  997. .finally(() => {
  998. apiCallInProgressCount--;
  999. });
  1000. }
  1001.  
  1002. function updateAllMemberTimers() {
  1003. const liElements = document.querySelectorAll(
  1004. ".enemy-faction .members-list li, .your-faction .members-list li",
  1005. );
  1006. liElements.forEach((li) => {
  1007. let profileLink = li.querySelector('a[href*="profiles.php?XID="]');
  1008. if (!profileLink) return;
  1009. let match = profileLink.href.match(/XID=(\d+)/);
  1010. if (!match) return;
  1011. let userID = match[1];
  1012. let statusEl = li.querySelector(".status");
  1013. if (!statusEl) return;
  1014. if (memberCountdowns[userID]) {
  1015. let remaining = memberCountdowns[userID] * 1000 - Date.now();
  1016. if (remaining < 0) remaining = 0;
  1017. statusEl.textContent = formatTime(remaining);
  1018. }
  1019. });
  1020. }
  1021.  
  1022. function updateAPICalls() {
  1023. let enemyFactionLink = document.querySelector(
  1024. ".opponentFactionName___vhESM",
  1025. );
  1026. let yourFactionLink = document.querySelector(".currentFactionName___eq7n8");
  1027. if (!enemyFactionLink || !yourFactionLink) return;
  1028.  
  1029. let enemyFactionIdMatch = enemyFactionLink.href.match(/ID=(\d+)/);
  1030. let yourFactionIdMatch = yourFactionLink.href.match(/ID=(\d+)/);
  1031. if (!enemyFactionIdMatch || !yourFactionIdMatch) return;
  1032.  
  1033. let enemyList = document.querySelector(".enemy-faction .members-list");
  1034. let yourList = document.querySelector(".your-faction .members-list");
  1035. if (!enemyList || !yourList) return;
  1036.  
  1037. updateFactionStatuses(enemyFactionIdMatch[1], enemyList);
  1038. updateFactionStatuses(yourFactionIdMatch[1], yourList);
  1039. }
  1040.  
  1041. function initWarScript() {
  1042. let enemyFactionLink = document.querySelector(
  1043. ".opponentFactionName___vhESM",
  1044. );
  1045. let yourFactionLink = document.querySelector(".currentFactionName___eq7n8");
  1046. if (!enemyFactionLink || !yourFactionLink) return false;
  1047.  
  1048. let enemyList = document.querySelector(".enemy-faction .members-list");
  1049. let yourList = document.querySelector(".your-faction .members-list");
  1050. if (!enemyList || !yourList) return false;
  1051.  
  1052. updateAPICalls();
  1053. setInterval(updateAPICalls, API_INTERVAL);
  1054. console.log(
  1055. "[FF Scouter V2] Torn Faction Status Countdown (Real-Time & API Status - Relative Last): Initialized",
  1056. );
  1057. return true;
  1058. }
  1059.  
  1060. let warObserver = new MutationObserver((mutations, obs) => {
  1061. if (initWarScript()) {
  1062. obs.disconnect();
  1063. }
  1064. });
  1065. if (!document.getElementById("FFScouterV2DisableWarMonitor")) {
  1066. warObserver.observe(document.body, { childList: true, subtree: true });
  1067.  
  1068. const memberTimersInterval = setInterval(updateAllMemberTimers, 1000);
  1069.  
  1070. window.addEventListener("FFScouterV2DisableWarMonitor", () => {
  1071. console.log(
  1072. "[FF Scouter V2] Caught disable event, removing monitoring observer and interval",
  1073. );
  1074. warObserver.disconnect();
  1075.  
  1076. clearInterval(memberTimersInterval);
  1077. });
  1078. }
  1079. // Try to be friendly and detect other war monitoring scripts
  1080. const catchOtherScripts = () => {
  1081. if (
  1082. Array.from(document.querySelectorAll("style")).some(
  1083. (style) =>
  1084. style.textContent.includes(
  1085. '.members-list li:has(div.status[data-twse-highlight="true"])', // Torn War Stuff Enhanced
  1086. ) ||
  1087. style.textContent.includes(".warstuff_highlight") || // Torn War Stuff
  1088. style.textContent.includes(".finally-bs-stat"), // wall-battlestats
  1089. )
  1090. ) {
  1091. window.dispatchEvent(new Event("FFScouterV2DisableWarMonitor"));
  1092. }
  1093. };
  1094. catchOtherScripts();
  1095. setTimeout(catchOtherScripts, 500);
  1096.  
  1097. function showToast(message) {
  1098. const existing = document.getElementById("ffscouter-toast");
  1099. if (existing) existing.remove();
  1100.  
  1101. const toast = document.createElement("div");
  1102. toast.id = "ffscouter-toast";
  1103. toast.style.position = "fixed";
  1104. toast.style.bottom = "30px";
  1105. toast.style.left = "50%";
  1106. toast.style.transform = "translateX(-50%)";
  1107. toast.style.background = "#c62828";
  1108. toast.style.color = "#fff";
  1109. toast.style.padding = "8px 16px";
  1110. toast.style.borderRadius = "8px";
  1111. toast.style.fontSize = "14px";
  1112. toast.style.boxShadow = "0 2px 12px rgba(0,0,0,0.2)";
  1113. toast.style.zIndex = "2147483647";
  1114. toast.style.opacity = "1";
  1115. toast.style.transition = "opacity 0.5s";
  1116. toast.style.display = "flex";
  1117. toast.style.alignItems = "center";
  1118. toast.style.gap = "10px";
  1119.  
  1120. // Close button
  1121. const closeBtn = document.createElement("span");
  1122. closeBtn.textContent = "×";
  1123. closeBtn.style.cursor = "pointer";
  1124. closeBtn.style.marginLeft = "8px";
  1125. closeBtn.style.fontWeight = "bold";
  1126. closeBtn.style.fontSize = "18px";
  1127. closeBtn.setAttribute("aria-label", "Close");
  1128. closeBtn.onclick = () => toast.remove();
  1129.  
  1130. const msg = document.createElement("span");
  1131. if (
  1132. message ===
  1133. "Invalid API key. Please sign up at ffscouter.com to use this service"
  1134. ) {
  1135. msg.innerHTML =
  1136. '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';
  1137. } else {
  1138. msg.textContent = `FairFight Scouter: ${message}`;
  1139. }
  1140.  
  1141. toast.appendChild(msg);
  1142. toast.appendChild(closeBtn);
  1143. document.body.appendChild(toast);
  1144. setTimeout(() => {
  1145. if (toast.parentNode) {
  1146. toast.style.opacity = "0";
  1147. setTimeout(() => toast.remove(), 500);
  1148. }
  1149. }, 4000);
  1150. }
  1151. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址