Target list helper

Make FF visible, enable attack buttons, list target hp or remaining hosp time

  1. // ==UserScript==
  2. // @name Target list helper
  3. // @namespace szanti
  4. // @license GPL-3.0-or-later
  5. // @match https://www.torn.com/page.php?sid=list&type=targets*
  6. // @grant GM.xmlHttpRequest
  7. // @grant GM_getValue
  8. // @grant GM_setValue
  9. // @grant GM_deleteValue
  10. // @grant GM_registerMenuCommand
  11. // @grant GM_addStyle
  12. // @version 2.0.2
  13. // @author Szanti
  14. // @description Make FF visible, enable attack buttons, list target hp or remaining hosp time
  15. // ==/UserScript==
  16.  
  17. const API_KEY = "###PDA-APIKEY###"
  18. const POLLING_INTERVAL = undefined
  19. const STALE_TIME = undefined
  20. const SHOW = undefined // Show.LEVEL // Show.RESPECT
  21. const USE_TORNPAL = undefined // Tornpal.YES // Tornpal.NO // Tornpal.WAIT_FOR_TT
  22.  
  23. const UseTornPal = Object.freeze({
  24. YES: "Trying TornPal then TornTools",
  25. NO: "Disabled TornPal, trying only TornTools",
  26. WAIT_FOR_TT: "Trying TornTools then TornPal"
  27. })
  28.  
  29. const Show = Object.freeze({
  30. LEVEL: "Showing Level",
  31. RESPECT: "Showing Respect",
  32. RESP_UNAVAILABLE: "Can't show respect without fair fight estimation"
  33. })
  34.  
  35. {(async function() {
  36. 'use strict'
  37.  
  38. if(isPda()) {
  39. // On TornPDA resorting the list leads to the entire script being reloaded
  40. if(window.target_list_helper_loaded)
  41. return
  42. window.target_list_helper_loaded = true
  43.  
  44. GM.xmlHttpRequest = GM.xmlhttpRequest
  45. GM_getValue = (key, default_value) => {
  46. const value = GM.getValue(key)
  47. return value ? JSON.parse(value) : default_value
  48. }
  49.  
  50. GM_setValue = (key, value) => GM.setValue(key, JSON.stringify(value))
  51. }
  52.  
  53. let api_key = GM_getValue("api-key", API_KEY)
  54. // Amount of time between each API call
  55. let polling_interval = GM_getValue("polling-interval", POLLING_INTERVAL ?? 1000)
  56. // Least amount of time after which to update data
  57. let stale_time = GM_getValue("stale-time", STALE_TIME ?? 300_000)
  58. // Show level or respect
  59. let show_respect = loadEnum(Show, GM_getValue("show-respect", SHOW ?? Show.RESPECT))
  60. // Torntools is definitely inaccessible on PDA dont bother waiting for it
  61. let use_tornpal =
  62. loadEnum(
  63. UseTornPal,
  64. GM_getValue("use-tornpal", USE_TORNPAL ?? (isPda() ? UseTornPal.YES : UseTornPal.WAIT_FOR_TT)))
  65.  
  66. // How long until we stop looking for the hospitalization after a possible attack
  67. const CONSIDER_ATTACK_FAILED = 15_000
  68. // Time after which a target coming out of hospital is updated
  69. const OUT_OF_HOSP = 60_000
  70. // It's ok to display stale data until it can get updated but not invalid data
  71. const INVALIDATION_TIME = Math.max(900_000, stale_time)
  72.  
  73. // Our data cache
  74. let targets = GM_getValue("targets", {})
  75. // In queue for profile data update, may need to be replaced with a filtered array on unpause
  76. let profile_updates = []
  77. // In queue for TornPal update
  78. const ff_updates = []
  79. // Update attacked targets when regaining focus
  80. let attacked_targets = []
  81. // If the api key can be used for tornpal, assume it works, fail if not
  82. let can_tornpal = true
  83. // To TornTool or not to TornTool
  84. const torntools = !(document.documentElement.style.getPropertyValue("--tt-theme-color").length == 0)
  85. if(!torntools && use_tornpal == UseTornPal.NO) {
  86. console.warn("[Target list helper] Couldn't find TornTools and TornPal is deactivated, FF estimation unavailable.")
  87. show_respect = Show.RESP_UNAVAILABLE
  88. }
  89.  
  90. const number_format = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2 , maximumFractionDigits: 2 })
  91. const timer_format = new Intl.DurationFormat("en-US", { style: "digital", fractionalDigits: 0, hoursDisplay: "auto"})
  92.  
  93. // This is how to fill in react input values so they register
  94. const native_input_value_setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
  95.  
  96. const icons =
  97. { "rock": "🪨",
  98. "paper": "📜",
  99. "scissors": "✂️" }
  100.  
  101. const Debug = {
  102. API_LOOP: Symbol("Debug.API_LOOP"),
  103. UPDATE: Symbol("Debug.UPDATE")
  104. }
  105.  
  106. /**
  107. *
  108. * ATTACH CSS FOR FLASH EFFECT
  109. *
  110. **/
  111. GM_addStyle(`
  112. @keyframes green_flash {
  113. 0% {background-color: var(--default-bg-panel-color);}
  114. 50% {background-color: oklab(from var(--default-bg-panel-color) L -0.087 0.106); }
  115. 100% {background-color: var(--default-bg-panel-color);}
  116. }
  117. .flash_green {
  118. animation: green_flash 500ms ease-in-out;
  119. animation-iteration-count: 1;
  120. }
  121. `)
  122.  
  123. /**
  124. *
  125. * ASSETS
  126. *
  127. **/
  128. const refresh_button =
  129. (function makeRefreshButton(){
  130. const button = document.createElement("button")
  131. const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  132. icon.setAttribute("width", 16)
  133. icon.setAttribute("height", 15)
  134. icon.setAttribute("viewBox", "0 0 16 15")
  135. const icon_path = document.createElementNS("http://www.w3.org/2000/svg", "path")
  136. icon_path.setAttribute("d", "M9,0A7,7,0,0,0,2.09,6.83H0l3.13,3.5,3.13-3.5H3.83A5.22,5.22,0,1,1,9,12.25a5.15,5.15,0,0,1-3.08-1l-1.2,1.29A6.9,6.9,0,0,0,9,14,7,7,0,0,0,9,0Z")
  137. icon.appendChild(icon_path)
  138. button.appendChild(icon)
  139. return button
  140. })()
  141.  
  142. const copy_bss_button =
  143. (function makeCopyBssButton(){
  144. const button = document.createElement("button")
  145. const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  146. icon.setAttribute("width", 16)
  147. icon.setAttribute("height", 13)
  148. icon.setAttribute("viewBox", "0 0 16 13")
  149. const icon_path_1 = document.createElementNS("http://www.w3.org/2000/svg", "path")
  150. icon_path_1.setAttribute("d", "M16,13S14.22,4.41,6.42,4.41V1L0,6.7l6.42,5.9V8.75c4.24,0,7.37.38,9.58,4.25")
  151. icon.append(icon_path_1)
  152. const icon_path_2 = document.createElementNS("http://www.w3.org/2000/svg", "path")
  153. icon_path_2.setAttribute("d", "M16,12S14.22,3.41,6.42,3.41V0L0,5.7l6.42,5.9V7.75c4.24,0,7.37.38,9.58,4.25")
  154. icon.append(icon_path_2)
  155. button.appendChild(icon)
  156. return button
  157. })()
  158.  
  159. /**
  160. *
  161. * REGISTER MENU COMMANDS
  162. *
  163. **/
  164. {
  165. try {
  166. GM_registerMenuCommand('Set Api Key', function setApiKey() {
  167. const new_key = prompt("Please enter a public api key", api_key)
  168. if (new_key?.length == 16) {
  169. GM_setValue("api-key", new_key)
  170. api_key = new_key
  171. can_tornpal = true
  172. for(const row of document.querySelector(".tableWrapper > ul").childNodes) updateFf(row)
  173. } else {
  174. throw new Error("No valid key detected.")
  175. }
  176. })
  177. } catch (e) {
  178. if(api_key.charAt(0) === "#")
  179. throw new Error("Please set the public or TornPal capable api key in the script manually on line 18.")
  180. }
  181.  
  182. try {
  183. let menu_id = GM_registerMenuCommand(
  184. use_tornpal,
  185. function toggleTornPal() {
  186. use_tornpal = next_state()
  187. GM_setValue("use-tornpal", use_tornpal)
  188. menu_id = GM_registerMenuCommand(
  189. use_tornpal,
  190. toggleTornPal,
  191. {id: menu_id, autoClose: false}
  192. )
  193. },
  194. {autoClose: false})
  195.  
  196. function next_state() {
  197. if(use_tornpal == UseTornPal.WAIT_FOR_TT)
  198. return UseTornPal.YES
  199. if(use_tornpal == UseTornPal.YES)
  200. return UseTornPal.NO
  201. return UseTornPal.WAIT_FOR_TT
  202. }
  203. } catch(e) {
  204. if(USE_TORNPAL === undefined)
  205. console.warn("[Target list helper] Please choose UseTornPal.YES, UseTornPal.NO or UseTornPal.WAIT_FOR_TT on line 22. (Default: UseTornPal.WAIT_FOR_TT)")
  206. }
  207.  
  208. try {
  209. GM_registerMenuCommand('Api polling interval', function setPollingInterval() {
  210. const new_polling_interval = prompt("How often in ms should the api be called (default 1000)?",polling_interval)
  211. if (Number.isFinite(new_polling_interval)) {
  212. polling_interval = new_polling_interval
  213. GM_setValue("polling-interval", new_polling_interval)
  214. } else {
  215. throw new Error("Please enter a numeric polling interval.")
  216. }
  217. })
  218. } catch (e) {
  219. if(POLLING_INTERVAL === undefined)
  220. console.warn("[Target list helper] Please set the api polling interval (in ms) on line 19. (default 1000ms)")
  221. }
  222.  
  223. try {
  224. GM_registerMenuCommand('Set Stale Time', function setStaleTime() {
  225. const new_stale_time = prompt("After how many seconds should data about a target be considered stale (default 300)?", stale_time/1000)
  226. if (Number.isFinite(new_stale_time)) {
  227. stale_time = new_stale_time
  228. GM_setValue("stale-time", new_stale_time*1000)
  229. } else {
  230. throw new Error("Please enter a numeric stale time.")
  231. }
  232. })
  233. } catch (e) {
  234. if(STALE_TIME === undefined)
  235. console.warn("[Target list helper] Please set the stale time (in ms) on line 20. (default 5 minutes)")
  236. }
  237.  
  238. try {
  239. let menu_id = GM_registerMenuCommand(
  240. show_respect,
  241. function toggleRespect() {
  242. const old_show_respect = show_respect
  243. show_respect = next_state()
  244. try {
  245. for(const row of document.querySelector(".tableWrapper > ul").childNodes) redrawFf(row)
  246. } catch(e) { // Maybe the user clicks it before fair fight is loaded
  247. show_respect = old_show_respect
  248. throw e
  249. }
  250. setFfColHeader()
  251. if(show_respect != Show.RESP_UNAVAILABLE)
  252. GM_setValue("show-respect", show_respect)
  253. menu_id = GM_registerMenuCommand(
  254. show_respect,
  255. toggleRespect,
  256. {id: menu_id, autoClose: false}
  257. )
  258. },
  259. {autoClose: false}
  260. )
  261.  
  262. function next_state() {
  263. if((use_tornpal == UseTornPal.NO || !can_tornpal) && !torntools)
  264. return Show.RESP_UNAVAILABLE
  265. if(show_respect == Show.RESPECT)
  266. return Show.LEVEL
  267. return Show.RESPECT
  268. }
  269. } catch(e) {
  270. if(SHOW === undefined)
  271. console.warn("[Target list helper] Please select if you want to see estimated respect Show.RESPECT or Show.LEVEL on line 21. (Default Show.RESPECT)")
  272. }
  273. }
  274.  
  275. /**
  276. *
  277. * THE SCRIPT PROPER
  278. *
  279. **/
  280. const row_list = await waitForElement(".tableWrapper > ul", document.getElementById("users-list-root"))
  281.  
  282. const table = row_list.parentNode
  283. const table_head = table.querySelector("[class*=tableHead]")
  284. const description_header = table_head.querySelector("[class*=description___]")
  285. waitForElement("[aria-label='Remove player from the list']", row_list)
  286. .then(button => {
  287. if(button.getAttribute("data-is-tooltip-opened") != null)
  288. description_header.style.maxWidth = (description_header.scrollWidth - button.scrollWidth) + "px"
  289. })
  290.  
  291. setFfColHeader()
  292. table_head.insertBefore(description_header, table_head.querySelector("[class*=level___]"))
  293.  
  294. parseTable(row_list)
  295.  
  296. // Observe changes after resorting
  297. new MutationObserver(records => {
  298. records.forEach(r =>
  299. r.addedNodes.forEach(n => {
  300. if(n.tagName === "UL") parseTable(n)
  301. }))})
  302. .observe(table, {childList: true})
  303.  
  304. const loop_id = crypto.randomUUID()
  305. let idle_start = undefined
  306. let process_responses = []
  307. GM_setValue("main-loop", loop_id)
  308. GM_setValue("has-lock", loop_id)
  309.  
  310. addEventListener("focus", function refocus() {
  311. GM_setValue("main-loop", loop_id)
  312. while(attacked_targets.length > 0)
  313. updateUntilHospitalized(attacked_targets.pop(), CONSIDER_ATTACK_FAILED)
  314. })
  315.  
  316. setInterval(mainLoop, polling_interval)
  317.  
  318. function mainLoop() {
  319. const jobs_waiting = profile_updates.length > 0 || ff_updates.length > 0 || process_responses.length > 0
  320. let has_lock = GM_getValue("has-lock")
  321.  
  322. if(jobs_waiting && has_lock != loop_id && (has_lock === undefined || GM_getValue("main-loop") == loop_id)) {
  323. GM_setValue("has-lock", loop_id)
  324. has_lock = loop_id
  325.  
  326. Object.assign(targets, GM_getValue("targets", {}))
  327. profile_updates =
  328. profile_updates
  329. .filter(row => {
  330. const t = targets[getId(row)]
  331. if(!t?.timestamp || t.timestamp < idle_start)
  332. return true
  333. finishUpdate(row)
  334. return false
  335. })
  336. } else if(!jobs_waiting && has_lock == loop_id) {
  337. GM_deleteValue("has-lock", undefined)
  338. has_lock = undefined
  339. }
  340.  
  341. if(has_lock != loop_id) {
  342. idle_start = Date.now()
  343. return
  344. }
  345.  
  346. while(process_responses.length > 0)
  347. process_responses.pop()()
  348.  
  349. GM_setValue("targets", targets)
  350.  
  351. if(api_key.charAt(0) === "#")
  352. return
  353.  
  354. /**
  355. *
  356. * TornPal updates
  357. *
  358. **/
  359. if(ff_updates.length > 0) {
  360. const scouts = ff_updates.splice(0,250)
  361. GM.xmlHttpRequest({
  362. url: `https://tornpal.com/api/v1/ffscoutergroup?comment=targetlisthelper&key=${api_key}&targets=${scouts.map(getId).join(",")}`,
  363. onload: function updateFf({responseText}) {
  364. const r = JSON.parse(responseText)
  365. if(!r.status) {
  366. if(r.error_code == 772) {
  367. can_tornpal = false
  368. if(!torntools)
  369. show_respect = Show.RESP_UNAVAILABLE
  370. }
  371. throw new Error("TornPal error: " + r.message)
  372. }
  373.  
  374. process_responses.push(() => {
  375. Object.values(r.results)
  376. .forEach((result) => {
  377. if(result.status)
  378. targets[result.result.player_id].fair_fight = {last_updated: result.result.last_updated*1000, value: result.result.value}
  379. })
  380. setTimeout(() => {
  381. scouts.forEach(row => {
  382. if(targets[getId(row)].fair_fight)
  383. redrawFf(row)
  384. })
  385. })
  386. })
  387. }
  388. })
  389. }
  390.  
  391. /**
  392. *
  393. * Torn profile updates
  394. *
  395. **/
  396. let row
  397. while(profile_updates.length > 0 && !row?.isConnected)
  398. row = profile_updates.shift()
  399.  
  400. if(!row)
  401. return
  402.  
  403. const id = getId(row)
  404.  
  405. GM.xmlHttpRequest({
  406. url: `https://api.torn.com/user/${id}?key=${api_key}&selections=profile`,
  407. onload: function updateProfile({responseText}) {
  408. let r = undefined
  409. try {
  410. r = JSON.parse(responseText) // Can also throw on malformed response
  411. if(r.error)
  412. throw new Error("Torn error: " + r.error.error)
  413. } catch (e) {
  414. profile_updates.unshift(row) // Oh Fuck, Put It Back In
  415. throw e
  416. }
  417.  
  418. const response_date = Date.now()
  419.  
  420. process_responses.push(() => {
  421. if(targets[id].timestamp === undefined || targets[id].timestamp <= response_date) {
  422. Object.assign(targets[id], {
  423. timestamp: response_date,
  424. icon: icons[r.competition.status] ?? r.competition.status ?? "",
  425. hospital: r.status.until == 0 ? Math.min(targets[id]?.hospital ?? 0, Date.now()) : r.status.until*1000,
  426. life: r.life,
  427. status: r.status.state,
  428. last_action: r.last_action.timestamp*1000,
  429. level: r.level
  430. })
  431. }
  432. finishUpdate(row)
  433. })
  434. }
  435. })
  436.  
  437. function finishUpdate(row) {
  438. row.updating = false
  439. row.fast_tracked = false
  440.  
  441. setTimeout(() => {
  442. row.classList.add('flash_green');
  443. setTimeout(() => row.classList.remove('flash_green'), 500)
  444.  
  445. redrawStatus(row)
  446. updateStatus(row, targets[getId(row)].timestamp + stale_time)
  447. })
  448. }
  449. }
  450.  
  451. function parseTable(table) {
  452. parseRows(table.childNodes)
  453.  
  454. // Observe new rows getting added
  455. new MutationObserver(
  456. records => records.forEach(r => parseRows(r.addedNodes))
  457. ).observe(table, {childList: true})
  458.  
  459. function parseRows(rows) {
  460. for(const row of rows) {
  461. if(row.classList.contains("tornPreloader"))
  462. continue
  463.  
  464. const id = getId(row)
  465. const target = targets[id]
  466. const level_from_page = Number(row.querySelector("[class*='level___']").textContent)
  467. const status_from_page = row.querySelector("[class*='status___'] > span").textContent
  468.  
  469. reworkRow()
  470.  
  471. new MutationObserver(records =>
  472. records.forEach(r =>
  473. r.addedNodes.forEach(n => {
  474. if(n.className.includes("buttonsGroup")) reworkRow()
  475. })))
  476. .observe(row, {childList: true})
  477.  
  478. if(target?.timestamp + INVALIDATION_TIME > Date.now() && status_from_page === target?.status) {
  479. redrawStatus(row)
  480. updateStatus(row, target.timestamp + stale_time)
  481. } else {
  482. targets[id] = {level: level_from_page, status: status_from_page, fair_fight: target?.fair_fight}
  483. if(status_from_page === "Hospital")
  484. updateUntilHospitalized(row)
  485. else
  486. updateStatus(row)
  487. }
  488.  
  489. if(target?.fair_fight?.last_updated > target?.last_action)
  490. redrawFf(row)
  491. else
  492. updateFf(row)
  493.  
  494. function reworkRow() {
  495. // Switch description and Ff column
  496. const description = row.querySelector("[class*=description___]")
  497. const ff = row.querySelector("[class*='level___']")
  498. row.querySelector("[class*='contentGroup___']").insertBefore(description, ff)
  499.  
  500. const buttons_group = row.querySelector("[class*='buttonsGroup']")
  501. if(!buttons_group)
  502. return
  503.  
  504. const sample_button = buttons_group.querySelector("button:not([class*='disabled___'])")
  505. const disabled_button = buttons_group.querySelector("[class*='disabled___']")
  506. const edit_button = row.querySelector("[aria-label='Edit user descripton'], [aria-label='Edit player']")
  507. const wide_mode = sample_button.getAttribute("data-is-tooltip-opened") !== null
  508.  
  509. const new_refresh_button = refresh_button.cloneNode(true)
  510. sample_button.classList.forEach(c => new_refresh_button.classList.add(c))
  511. if(!wide_mode)
  512. new_refresh_button.append(document.createTextNode("Refresh"))
  513. buttons_group.prepend(new_refresh_button)
  514. new_refresh_button.addEventListener("click", () => updateStatus(row, Date.now(), true))
  515.  
  516. // Fix description width
  517. if(wide_mode)
  518. description.style.maxWidth = (description.scrollWidth - new_refresh_button.scrollWidth) + "px"
  519.  
  520. // Add BSS button
  521. edit_button?.addEventListener(
  522. "click",
  523. async function addBssButton() {
  524. const faction_el = row.querySelector("[class*='factionImage___']")
  525. const faction =
  526. faction_el?.getAttribute("alt") !== ""
  527. ? faction_el?.getAttribute("alt")
  528. : faction_el.parentNode.getAttribute("href").match(/[0-9]+/g)[0]
  529. const bss_str =
  530. "BSS: " + String(Math.round(((targets[id].fair_fight.value - 1)*3*getBss())/8)).padStart(6, ' ')
  531. + (faction ? " - " + faction : "")
  532.  
  533. const new_copy_bss_button = copy_bss_button.cloneNode(true)
  534.  
  535. const wrapper = await waitForElement("[class*='wrapper___']", row)
  536. wrapper.childNodes[1].classList.forEach(c => new_copy_bss_button.classList.add(c))
  537. wrapper.append(new_copy_bss_button)
  538.  
  539. new_copy_bss_button.addEventListener("click", (e) => {
  540. e.stopPropagation()
  541. native_input_value_setter.call(wrapper.childNodes[0], bss_str)
  542. wrapper.childNodes[0].dispatchEvent(new Event('input', { bubbles: true }))
  543. })
  544. if(wide_mode)
  545. waitForElement("[aria-label='Edit user descripton']", row)
  546. .then(button => { button.addEventListener("click", addBssButton) })
  547. })
  548.  
  549. // Enable attack buttons and make them report if they're clicked
  550. if(disabled_button) {
  551. const a = document.createElement("a")
  552. a.href = `/loader2.php?sid=getInAttack&user2ID=${id}`
  553. disabled_button.childNodes.forEach(n => a.appendChild(n))
  554. disabled_button.classList.forEach(c => {
  555. if(c.charAt(0) !== 'd')
  556. a.classList.add(c)
  557. })
  558. disabled_button.parentNode.insertBefore(a, disabled_button)
  559. disabled_button.parentNode.removeChild(disabled_button)
  560. }
  561. (disabled_button ?? buttons_group.querySelector("a")).addEventListener("click", () => attacked_targets.push(row))
  562. }
  563. }
  564.  
  565. profile_updates.sort(
  566. function prioritizeUpdates(a, b) {
  567. return updateValue(b) - updateValue(a)
  568.  
  569. function updateValue(row) {
  570. const target = targets[getId(row)]
  571. if(!target?.timestamp || target.timestamp + INVALIDATION_TIME < Date.now())
  572. return Infinity
  573.  
  574. if(target.life.current < target.life.maximum)
  575. return Date.now() + target.timestamp
  576.  
  577. return target.timestamp
  578. }
  579. })
  580. }
  581. }
  582.  
  583. function redrawStatus(row) {
  584. const target = targets[getId(row)]
  585. const status_element = row.querySelector("[class*='status___'] > span")
  586.  
  587. setStatus()
  588.  
  589. if(target.status === "Okay" && Date.now() > target.hospital + OUT_OF_HOSP) {
  590. status_element.classList.replace("user-red-status", "user-green-status")
  591. } else if(target.status === "Hospital") {
  592. status_element.classList.replace("user-green-status", "user-red-status")
  593. if(target.hospital < Date.now()) // Defeated but not yet selected where to put
  594. updateUntilHospitalized(row)
  595. else
  596. updateStatus(row, target.hospital + OUT_OF_HOSP)
  597.  
  598. /* To make sure we dont run two timers on the same row in parallel, *
  599. * we make the sure that a row has at most one timer id. */
  600. let last_timer = row.timer =
  601. setTimeout(function updateTimer() {
  602. const time_left = target.hospital - Date.now()
  603.  
  604. if(time_left > 0 && last_timer == row.timer) {
  605. status_element.textContent =
  606. timer_format.format({minutes: Math.trunc(time_left/60_000), seconds: Math.trunc(time_left/1000%60)})
  607. + " " + target.icon
  608. last_timer = row.timer = setTimeout(updateTimer,1000 - Date.now()%1000, row)
  609. } else if(time_left <= 0) {
  610. target.status = "Okay"
  611. setStatus(row)
  612. }
  613. })
  614. }
  615.  
  616. // Check if we need to register a healing tick in the interim
  617. if(row.health_update || target.life.current == target.life.maximum)
  618. return
  619.  
  620. let next_health_tick = target.timestamp + target.life.ticktime*1000
  621. if(next_health_tick < Date.now()) {
  622. const health_ticks = Math.ceil((Date.now() - next_health_tick)/(target.life.interval * 1000))
  623. target.life.current = Math.min(target.life.maximum, target.life.current + health_ticks * target.life.increment)
  624. next_health_tick = next_health_tick + health_ticks * target.life.interval * 1000
  625. target.life.ticktime = next_health_tick - target.timestamp
  626. setStatus(row)
  627. }
  628.  
  629. row.health_update =
  630. setTimeout(function updateHealth() {
  631. target.life.current = Math.min(target.life.maximum, target.life.current + target.life.increment)
  632. target.ticktime = Date.now() + target.life.interval*1000 - target.timestamp
  633.  
  634. if(target.life.current < target.life.maximum)
  635. row.health_update = setTimeout(updateHealth, target.life.interval*1000)
  636. else
  637. row.health_update = undefined
  638.  
  639. setStatus(row)
  640. }, next_health_tick - Date.now())
  641.  
  642. function setStatus() {
  643. let status = status_element.textContent
  644.  
  645. if(target.status === "Okay")
  646. status = target.life.current + "/" + target.life.maximum
  647.  
  648. status_element.textContent = status + " " + target.icon
  649. }
  650. }
  651.  
  652. function redrawFf(row) {
  653. const target = targets[getId(row)]
  654. const ff = target.fair_fight.value
  655.  
  656. const text_element = row.querySelector("[class*='level___']")
  657. const respect = (1 + 0.005 * target.level) * Math.min(3, ff)
  658.  
  659. if(show_respect == Show.RESPECT)
  660. text_element.textContent = number_format.format(respect) + " " + number_format.format(ff)
  661. else
  662. text_element.textContent = target.level + " " + number_format.format(ff)
  663. }
  664.  
  665. function updateStatus(row, when, fast_track) {
  666. const requested_at = Date.now()
  667. const id = getId(row)
  668. if(fast_track && !row.fast_tracked) {
  669. row.updating = true
  670. row.fast_tracked = true
  671. profile_updates.unshift(row)
  672. return
  673. }
  674. setTimeout(() => {
  675. if(row.updating || targets[id]?.timestamp > requested_at)
  676. return
  677.  
  678. row.updating = true
  679. profile_updates.push(row)
  680. }, when - Date.now())
  681. }
  682.  
  683. function updateFf(row) {
  684. /**
  685. * UseTornPal | can_tornpal | torntools | case | action
  686. * ------------+---------------+-------------+------+--------
  687. * YES | YES | N/A | a | ff_updates.push
  688. * YES | NO | YES | e | try_tt (error when can_tornpal got set), fail silently
  689. * YES | NO | NO | b | fail silently (error whet can_tornpal got set)
  690. * NO | N/A | YES | d | try_tt, fail with error
  691. * NO | N/A | NO | b | fail silently (warn when torntools got set)
  692. * WAIT_FOR_TT | YES | YES | c | try_tt catch ff_updates.push
  693. * WAIT_FOR_TT | YES | NO | a | ff_updates.push
  694. * WAIT_FOR_TT | NO | YES | d | try_tt, fail with error
  695. * WAIT_FOR_TT | NO | NO | b | fail silently (error when can_tornpal got set)
  696. **/
  697. /** Case a - Only TornPal **/
  698. if((use_tornpal == UseTornPal.YES && can_tornpal)
  699. || (use_tornpal == UseTornPal.WAIT_FOR_TT && can_tornpal && !torntools)
  700. ) {
  701. ff_updates.push(row)
  702. return
  703. }
  704.  
  705. /** Case b - Neither TornPal nor Torntools **/
  706. if(!torntools)
  707. return
  708.  
  709. waitForElement(".tt-ff-scouter-indicator", row, 5000)
  710. .then(function ffFromTt(el) {
  711. const ff_perc = el.style.getPropertyValue("--band-percent")
  712. const ff =
  713. (ff_perc < 33) ? ff_perc/33+1
  714. : (ff_perc < 66) ? 2*ff_perc/33
  715. : (ff_perc - 66)*4/34+4
  716. const id = getId(row)
  717. Object.assign(targets[getId(row)], {fair_fight: {value: ff}})
  718. redrawFf(row)
  719. })
  720. .catch(function noTtFound(e) {
  721. /** Case c - TornTools failed so try TornPal next **/
  722. if(use_tornpal == UseTornPal.WAIT_FOR_TT && can_tornpal)
  723. ff_updates.push(row)
  724. /** Case d - TornTools failed but TornPal cannot be used**/
  725. else if(use_tornpal == UseTornPal.NO || use_tornpal == UseTornPal.WAIT_FOR_TT)
  726. console.error("[Target list helper] No fair fight estimation from TornPal or torntools for target " + getName(row) + " found. Is FF Scouter enabled?")
  727. /** Case e - User has enabled TornPal, likely because TornTools is not installed, but we tried it anyway. **/
  728. })
  729. }
  730.  
  731. function updateUntilHospitalized(row, time_out_after = INVALIDATION_TIME) {
  732. const id = getId(row)
  733. const start = Date.now()
  734. updateStatus(row)
  735. const attack_updater = setInterval(
  736. function attackUpdater() {
  737. updateStatus(row)
  738. if((targets[id]?.hospital > Date.now()) || Date.now() > start + time_out_after) {
  739. clearInterval(attack_updater)
  740. return
  741. }
  742. }, polling_interval)
  743. }
  744.  
  745. function getId(row) {
  746. if(!row.player_id)
  747. row.player_id = row.querySelector("[class*='honorWrap___'] > a").href.match(/\d+/)[0]
  748. return row.player_id
  749. }
  750.  
  751. function getName(row) {
  752. return row.querySelector(".honor-text-wrap > img").alt
  753. }
  754.  
  755. function setFfColHeader() {
  756. document
  757. .querySelector("[class*='level___'] > button")
  758. .childNodes[0]
  759. .data = show_respect == Show.RESPECT ? "R" : "Lvl"
  760. }
  761.  
  762. const {getBss} =
  763. (function bss() {
  764. let bss = undefined
  765.  
  766. GM.xmlHttpRequest({ url: `https://api.torn.com/user/?key=${api_key}&selections=battlestats` })
  767. .then(function setBss(response) {
  768. let r = undefined
  769. try {
  770. r = JSON.parse(response.responseText)
  771. if(r.error) throw Error(r.error.error)
  772. } catch(e) {
  773. console.error("Error getting battlestat score:", e)
  774. }
  775. bss = Math.sqrt(r.strength) + Math.sqrt(r.speed) + Math.sqrt(r.dexterity) + Math.sqrt(r.defense)
  776. })
  777.  
  778. function getBss() {
  779. return bss
  780. }
  781.  
  782. return {getBss}
  783. })()
  784.  
  785. function waitForElement(query_string, element = document, fail_after) {
  786. const el = element.querySelector(query_string)
  787. if(el)
  788. return Promise.resolve(el)
  789.  
  790. return new Promise((resolve, reject) => {
  791. let resolved = false
  792.  
  793. const observer = new MutationObserver(
  794. function checkElement() {
  795. observer.takeRecords()
  796. const el = element.querySelector(query_string)
  797. if(el) {
  798. resolved = true
  799. observer.disconnect()
  800. resolve(el)
  801. }
  802. })
  803.  
  804. observer.observe(element, {childList: true, subtree: true})
  805.  
  806. if(Number.isFinite(fail_after))
  807. setTimeout(() => {
  808. if(!resolved){
  809. observer.disconnect()
  810. reject(query_string + " not found.")
  811. }
  812. }, fail_after)
  813. })
  814. }
  815.  
  816. function isPda() {
  817. return window.navigator.userAgent.includes("com.manuito.tornpda")
  818. }
  819.  
  820. /** Ugly as fuck because we cant save what cant be stringified :/ **/
  821. function loadEnum(the_enum, loaded_value) {
  822. for(const [key,value] of Object.entries(the_enum)) {
  823. if(value === loaded_value)
  824. return the_enum[key]
  825. }
  826. return undefined
  827. }
  828. })()}