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.

// ==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")
  }
})()}