Target list helper

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

Tính đến 16-03-2025. Xem phiên bản mới nhất.

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

Bạn sẽ cần cài đặt một tiện ích mở rộng như Tampermonkey hoặc Violentmonkey để cài đặt kịch bản này.

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

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

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

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

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

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

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

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

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

// ==UserScript==
// @name        Target list helper
// @namespace   szanti
// @license     GPL
// @match       https://www.torn.com/page.php?sid=list&type=targets*
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @version     1.1.7
// @author      Szanti
// @description Make FF visible, enable attack buttons, list target hp or remaining hosp time
// ==/UserScript==

const API_KEY = "###PDA-APIKEY###"
const POLLING_INTERVAL = undefined
const STALE_TIME = undefined
const SHOW_RESPECT = undefined // true // false
const TRY_TORNPAL = undefined // true // false

{(function() {
  'use strict'

  if(isPda()) {
    GM_getValue = (key, default_value) => GM.getValue(key) ? JSON.parse(GM.getValue(key)) : default_value
    GM_setValue = (key, value) => GM.setValue(key, JSON.stringify(value))
  }

  let api_key = GM_getValue("api-key", API_KEY)
  // Amount of time between each API call
  let polling_interval = GM_getValue("polling-interval", POLLING_INTERVAL ?? 1000)
  // Least amount of time after which to update data
  let stale_time = GM_getValue("stale-time", STALE_TIME ?? 300_000)
  // Show level or respect
  let show_respect = GM_getValue("show-respect", SHOW_RESPECT ?? true)
  // Torntools is definitely inaccessible on PDA and on desktop people can probably
  // access the menu or at least edit the source code more easily if they need
  let try_tornpal = GM_getValue("try-tornpal", TRY_TORNPAL ?? isPda())

  // How often to try to find a specific condition on the page
  const MAX_TRIES_UNTIL_REJECTION = 5
  // How long to wait in between such tries
  const TRY_DELAY = 1000
  // Time after which a target out coming out of hospital is updated
  const OUT_OF_HOSP = 60_000
  // It's ok to display stale data until it can get updated but not invalid data
  const INVALID_TIME = Math.max(900_000, stale_time)

  // Our data cache
  const targets = GM_getValue("targets", {})
  // In queue for profile data update
  const profile_updates = []
  // In queue for TornPal update
  const ff_updates = []
  // If the api key can be used for tornpal
  let can_tornpal = undefined
  // So we don't start the main loop twice
  let main_loop = undefined

  const icons =
        { "rock": "🪨",
          "paper": "📜",
          "scissors": "✂️" }

  /**
   *
   * REGISTER MENU COMMANDS
   *
   **/
  try {
    GM_registerMenuCommand('Set Api Key', function setApiKey() {
      const new_key = prompt("Please enter a public api key", api_key);
      if (new_key && new_key.length == 16) {
        api_key = new_key;
        can_tornpal = undefined
        GM_setValue("api-key", new_key);
        startLoop()
      } else {
        throw new Error("No valid key detected.");
      }
    })
  } catch (e) {
    if(api_key.charAt(0) === "#")
      throw new Error("Please set the public api key in the script manually on line 17.")
  }

  try {
    let menu_id = GM_registerMenuCommand(try_tornpal ? "Disable TornPal" : "Enable TornPal", toggleTornPal)

    function toggleTornPal() {
      try_tornpal = !try_tornpal
      GM_setValue("try-tornpal", try_tornpal)
      menu_id = GM_registerMenuCommand(
        try_tornpal ? "Disable TornPal" : "Enable TornPal",
        toggleTornPal,
        {id: menu_id}
      )
    }
  } catch(e) {
    if(!TRY_TORNPAL)
      console.warn("If you want to enable TornPal please choose true on line 21 and make sure TornPal knows the API key in use.")
  }

  try {
    GM_registerMenuCommand('Api polling interval', function setPollingInterval() {
      const new_polling_interval = prompt("How often in ms should the api be called (default 1000)?",polling_interval);
      if (Number.isFinite(new_polling_interval)) {
        polling_interval = new_polling_interval;
        GM_setValue("polling-interval", new_polling_interval);
      } else {
        throw new Error("Please enter a numeric polling interval.");
      }
    });
  } catch (e) {
    if(!POLLING_INTERVAL)
      console.warn("Please set the api polling interval on line 18 manually if you wish a different value from the default 1000ms.")
  }

  try {
    GM_registerMenuCommand('Set Stale Time', function setStaleTime() {
      const new_stale_time = prompt("After how many seconds should data about a target be considered stale (default 300)?", stale_time/1000);
      if (Number.isFinite(new_stale_time)) {
        stale_time = new_stale_time;
        GM_setValue("stale-time", new_stale_time*1000);
      } else {
        throw new Error("Please enter a numeric stale time.");
      }
    })
  } catch (e) {
    if(!STALE_TIME)
      console.warn("Please set the stale time on line 19 manually if you wish a different value from the default 5 minutes.")
  }

  try {
    let menu_id = GM_registerMenuCommand(show_respect ? "Show level" : "Estimate respect", toggleRespect)

    function toggleRespect() {
      show_respect = !show_respect
      GM_setValue("show-respect", show_respect)
      for(const row of document.querySelector(".tableWrapper > ul").children) redrawFf(row)
      menu_id = GM_registerMenuCommand(
        show_respect ? "Show level" : "Estimate respect",
        toggleRespect,
        {id: menu_id}
      )
    }
  } catch(e) {
    if(!SHOW_RESPECT)
      console.warn("If you want to see the estimated respect, please choose true on line 20 and make sure FF Scouter or TornPal is turned on.")
  }

  /**
   *
   * SET UP SCRIPT
   *
   **/

  if(api_key.charAt(0) === "#")
    throw new Error("Please set the public api key in the script manually on line 17.")
  else
    startLoop()

  waitForElement(".tableWrapper > ul").then(
    function parseTable(table) {
      new MutationObserver((records) =>
        records.forEach(r => r.addedNodes.forEach(n => { if(n.tagName === "UL") parseTable(n) }))
      ).observe(table.parentNode, {childList: true})

      new MutationObserver((records) => records.forEach(r => parseRows(r.addedNodes))).observe(table, {childList: true})

      parseRows(table.children)

      function parseRows(rows) {
        for(const row of rows) {
          const target = targets[getId(row)]
          if(try_tornpal && can_tornpal != false
             && (target?.fair_fight?.last_updated === undefined || target?.fair_fight?.last_updated < target?.last_action))
            ff_updates.push(row)
          parseRow(row)
        }

        profile_updates.sort(apiSorter)

        function apiSorter(a, b) {
          return updateValue(b) - updateValue(a)

          function updateValue(row) {
            const target = targets[getId(row)]
            if(!target
               || target.timestamp + INVALID_TIME < Date.now()
               || row.querySelector("[class*='status___'] > span").textContent !== target.status
              )
              return Infinity

            if(target.life.current < target.life.maximum)
              return Date.now() + target.timestamp

            return target.timestamp
          }
        }

        function parseRow(row) {
          if(row.classList.contains("tornPreloader"))
            return

          const id = getId(row)
          if(!targets[id])
            targets[id] = {
              level: Number(row.querySelector("[class*='level___']").textContent)
            }
          const target = targets[id]

          if(!try_tornpal || !can_tornpal) {
            waitForElement(".tt-ff-scouter-indicator", row)
            .then(el => {
              const ff_perc = el.style.getPropertyValue("--band-percent")
              const ff =
                (ff_perc < 33) ? ff_perc/33+1
                : (ff_perc < 66) ? 2*ff_perc/33
                : (ff_perc - 66)*4/34+4
              //Object.assign(targets[id], {fair_fight: {value: ff}})
              //redrawFf(row)
            })
            .catch((e) => {console.log(e);throw new Error("Cannot find fair fight estimation from tornpal or from torntools.")})
          }

          const button = row.querySelector("[class*='disabled___']")
          if(button) {
            const a = document.createElement("a")
            a.href = `/loader2.php?sid=getInAttack&user2ID=${getId(row)}`
            button.childNodes.forEach(n => a.appendChild(n))
            button.classList.forEach(c => {
              if(c.charAt(0) != 'd')
                a.classList.add(c)
            })
            button.parentNode.insertBefore(a, button)
            button.parentNode.removeChild(button)
          }

          if(target.timestamp + INVALID_TIME > Date.now()
            && row.querySelector("[class*='status___'] > span").textContent === target.status
          )
            redrawStatus(row)
          else
            profile_updates.push(row)

          redrawFf(row)
        }
      }
  })

  function redrawStatus(row) {
    const target = targets[getId(row)]
    const status_element = row.querySelector("[class*='status___'] > span")

    setStatus()
    let next_update = target.timestamp + stale_time - Date.now()

    if(target.status === "Okay" && Date.now() > target.hospital + OUT_OF_HOSP) {
      status_element.classList.replace("user-red-status", "user-green-status")
    } else if(target.status === "Hospital") {
      status_element.classList.replace("user-green-status", "user-red-status")
      if(target.hospital < Date.now()) // Defeated but not yet selected where to put
        next_update = Math.min(next_update, 5000)
      else
        next_update = Math.min(next_update, target.hospital + OUT_OF_HOSP - Date.now())

      /* To make sure we dont run two timers on the same row in parallel, *
       * we make the sure that a row has at most one timer id.            */
      let last_timer = row.timer =
        setTimeout(function updateTimer() {
          const time_left = target.hospital - Date.now()

          if(time_left > 0 && last_timer == row.timer) {
            status_element.textContent = formatHospTime(time_left) + " " + target.icon
            last_timer = row.timer = setTimeout(updateTimer,1000 - Date.now()%1000, row)
          } else if(time_left <= 0) {
            target.status = "Okay"
            setStatus(row)
          }
        })
    }

    setTimeout(() => profile_updates.push(row), next_update)

    // Check if we need to register a healing tick in the interim
    if(row.health_update || target.life.current == target.life.maximum)
      return

    let next_health_tick = target.timestamp + target.life.ticktime*1000
    if(next_health_tick < Date.now()) {
      const health_ticks = Math.ceil((Date.now() - next_health_tick)/(target.life.interval * 1000))
      target.life.current = Math.min(target.life.maximum, target.life.current + health_ticks * target.life.increment)
      next_health_tick = next_health_tick + health_ticks * target.life.interval * 1000
      target.life.ticktime = next_health_tick - target.timestamp
      setStatus(row)
    }

    row.health_update =
      setTimeout(function updateHealth() {
        target.life.current = Math.min(target.life.maximum, target.life.current + target.life.increment)
        target.ticktime = Date.now() + target.life.interval*1000 - target.timestamp

        if(target.life.current < target.life.maximum)
          row.health_update = setTimeout(updateHealth, target.life.interval*1000)
        else
          row.health_update = undefined

        setStatus(row)
      }, next_health_tick - Date.now())

    function setStatus() {
      let status = status_element.textContent

      if(target.status === "Hospital")
        status = formatHospTime(target.hospital - Date.now())
      else if(target.status === "Okay")
        status = target.life.current + "/" + target.life.maximum

      status_element.textContent = status + " " + target.icon
    }

    function formatHospTime(time_left) {
      return String(Math.floor(time_left/60_000)).padStart(2, '0')
              + ":"
              + String(Math.floor((time_left/1000)%60)).padStart(2, '0')
    }
  }

  function redrawFf(row) {
    const target = targets[getId(row)]
    const ff = target?.fair_fight?.value

    if(!ff) return

    const text_element = row.querySelector("[class*='level___']")
    const respect = (1 + 0.005 * target.level) * Math.min(3, ff)
    if(show_respect)
      text_element.textContent = formatNumber(respect) + " " + formatNumber(ff)
    else
      text_element.textContent = target.level + " " + formatNumber(ff)

    function formatNumber(x) {
      return Math.floor(x) + "." + String(Math.floor((x%1)*100)).padStart(2, '0')
    }
  }

  function startLoop() {
    if(!main_loop)
      main_loop = setInterval(mainLoop, polling_interval)

    function mainLoop() {
      let row = profile_updates.shift()
      while(row && !row.isConnected)
        row = profile_updates.shift()

      if(!row)
        return

      const id = getId(row)

      GM_xmlhttpRequest({
        url: `https://api.torn.com/user/${id}?key=${api_key}&selections=profile`,
        onload: parseApi
      })

      if(try_tornpal && can_tornpal != false && ff_updates.length > 0) {
        console.log(ff_updates.map(getName).join(","))
        const scouts = ff_updates.splice(0,ff_updates.length).map(getId).join(",")
        GM_xmlhttpRequest({
          url: `https://tornpal.com/api/v1/ffscoutergroup?comment=targetlisthelper&key=${api_key}&targets=${scouts}`,
          onload: ({responseText}) => {
            const r = JSON.parse(responseText)
            if(!r.status) {
              if(r.error_code == 722)
                can_tornpal = false
              throw new Error("TornPal error: " + r.message)
            }
            Object.values(r.results)
              .filter(({status}) => status)
              .forEach(({result}) => {
                targets[result.player_id].fair_fight = {last_updated: result.last_updated, value: result.value}
              })
            for(const row of document.querySelector(".tableWrapper > ul").children) redrawFf(row)
          }
        })
      }

      function parseApi({responseText}) {
        let r = undefined
        try {
          r = JSON.parse(responseText) // Can also throw on malformed response
          if(r.error)
            throw new Error("Api error:", r.error.error)
        } catch (e) {
          profile_updates.unshift(row) // Oh Fuck, Put It Back In
          throw e
        }
        Object.assign(targets[id], {
          timestamp: Date.now(),
          icon: icons[r.competition.status] ?? r.competition.status,
          hospital: r.status.until == 0 ? Math.min(targets[id]?.hospital ?? 0, Date.now()) : r.status.until*1000,
          life: r.life,
          status: r.status.state,
          last_action: r.last_action.timestamp,
          level: r.level
        })
        GM_setValue("targets", targets)
        redrawStatus(row)
      }
    }
  }

  function getId(row) {
    return row.querySelector("[class*='honorWrap___'] > a").href.match(/\d+/)[0]
  }

  function getName(row) {
    return row.querySelectorAll(".honor-text").values().reduce((text, node) => node.textContent ?? text)
  }

  function waitForCondition(condition, silent_fail) {
    return new Promise((resolve, reject) => {
      let tries = 0
      const interval = setInterval(
        function conditionChecker() {
          const result = condition()
          tries += 1

          if(!result && tries <= MAX_TRIES_UNTIL_REJECTION)
            return

          clearInterval(interval)

          if(result)
            resolve(result)
          else if(!silent_fail)
            reject(result)
      }, TRY_DELAY)
    })
  }

  function waitForElement(query_string, element = document) {
    return waitForCondition(() => element.querySelector(query_string))
  }

  function isPda() {
    return window.navigator.userAgent.includes("com.manuito.tornpda")
  }
})()}