Target list helper

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

Versión del día 23/03/2025. Echa un vistazo a la versión más reciente.

// ==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
// @grant       GM_addStyle
// @version     1.2.0
// @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 = undefined // Show.LEVEL // Show.RESPECT
const USE_TORNPAL = undefined // Tornpal.YES // Tornpal.NO // Tornpal.WAIT_FOR_TT

const UseTornPal = Object.freeze({
  YES: "Trying TornPal then TornTools",
  NO: "Disabled TornPal, trying only TornTools",
  WAIT_FOR_TT: "Trying TornTools then TornPal"
})

const Show = Object.freeze({
  LEVEL: "Showing Level",
  RESPECT: "Showing Respect",
  RESP_UNAVAILABLE: "Can't show respect without fair fight estimation"
})

{(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 = loadEnum(Show, GM_getValue("show-respect", SHOW ?? Show.RESPECT))
  // Torntools is definitely inaccessible on PDA dont bother waiting for it
  let use_tornpal =
      loadEnum(
        UseTornPal,
        GM_getValue("use-tornpal", USE_TORNPAL ?? (isPda() ? UseTornPal.YES : UseTornPal.WAIT_FOR_TT)))

  // 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
  // How long until we consider stop looking for the hospitaliztion after a possible attack
  const DEF_NOT_HOSPITAL = 15_000
  // Time after which a target 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 INVALIDATION_TIME = Math.max(900_000, stale_time)

  // Our data cache
  let targets = GM_getValue("targets", {})
  // In queue for profile data update, may need to be replaced with filtered array on unpause
  let profile_updates = []
  // In queue for TornPal update
  const ff_updates = []
  // Update attacked targets when regaining focus
  let attacked_targets = []
  // If the api key can be used for tornpal, assume it works fail if not
  let can_tornpal = true
  // To TornTool or not to TornTool
  const torntools = !(document.documentElement.style.getPropertyValue("--tt-theme-color").length == 0)
  if(!torntools && use_tornpal == UseTornPal.NO) {
    console.warn("Couldn't find TornTools and TornPal is deactivated, FF estimation unavailable.")
    show_respect = Show.RESP_UNAVAILABLE
  }

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

  const Debug = {
    API_LOOP: Symbol("Debug.API_LOOP"),
    UPDATE: Symbol("Debug.UPDATE")
  }

  /**
   *
   * ATTACH CSS FOR FLASH EFFECT
   *
   **/
  GM_addStyle(`
    @keyframes green_flash {
      0% {background-color: var(--default-bg-panel-color);}
      50% {background-color: oklab(from var(--default-bg-panel-color) L -0.087 0.106); }
      100% {background-color: var(--default-bg-panel-color);}
    }
    .flash_green {
      animation: green_flash 500ms ease-in-out;
      animation-iteration-count: 1;
    }
  `)

  /**
   *
   * 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?.length == 16) {
        GM_setValue("api-key", new_key)
        api_key = new_key
        can_tornpal = true
        for(const row of document.querySelector(".tableWrapper > ul").children) updateFf(row)
      } else {
        throw new Error("No valid key detected.")
      }
    })
  } catch (e) {
    if(api_key.charAt(0) === "#")
      throw new Error("Please set the public or TornPal capable api key in the script manually on line 17.")
  }

  try {
    let menu_id = GM_registerMenuCommand(
      use_tornpal,
      function toggleTornPal() {
        use_tornpal = next_state()
        GM_setValue("use-tornpal", use_tornpal)
        menu_id = GM_registerMenuCommand(
          use_tornpal,
          toggleTornPal,
          {id: menu_id, autoClose: false}
        )
      },
      {autoClose: false})

    function next_state() {
      if(use_tornpal == UseTornPal.WAIT_FOR_TT)
        return UseTornPal.YES
      if(use_tornpal == UseTornPal.YES)
        return UseTornPal.NO
      return UseTornPal.WAIT_FOR_TT
    }
  } catch(e) {
    if(USE_TORNPAL === undefined)
      console.warn("Please choose UseTornPal.YES, UseTornPal.NO or UseTornPal.WAIT_FOR_TT on line 22. (Default: UseTornPal.WAIT_FOR_TT)")
  }

  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 === undefined)
      console.warn("Please set the api polling interval (in ms) on line 18. (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 === undefined)
      console.warn("Please set the stale time (in ms) on line 19. (default 5 minutes)")
  }

  try {
    let menu_id = GM_registerMenuCommand(
      show_respect,
      function toggleRespect() {
        const old_show_respect = show_respect
        show_respect = next_state()
        try {
          for(const row of document.querySelector(".tableWrapper > ul").children) redrawFf(row)
        } catch(e) { // Maybe the user clicks it before fair fight is loaded
          show_respect = old_show_respect
          throw e
        }
        setFfColHeader()
        GM_setValue("show-respect", show_respect)
        menu_id = GM_registerMenuCommand(
          show_respect,
          toggleRespect,
          {id: menu_id, autoClose: false}
        )
      },
      {autoClose: false}
    )

    function next_state() {
      if(use_tornpal == UseTornPal.NO || (!can_tornpal && !torntools))
        return Show.RESP_UNAVAILABLE
      if(show_respect == Show.RESPECT)
        return Show.LEVEL
      return Show.RESPECT
    }
  } catch(e) {
    if(SHOW === undefined)
      console.warn("Please select if you want to see estimated respect Show.RESPECT or Show.LEVEL on line 20. (Default Show.RESPECT)")
  }

  /**
   *
   * SET UP SCRIPT
   *
   **/
  waitForElement(".tableWrapper > ul")
  .then(function attachToTable(table) {
    const wrapper = table.parentNode

    const button = table.querySelector("[class*='buttonsGroup'] > button")
    if(button?.getAttribute("data-is-tooltip-opened") != null) {
      const description = wrapper.querySelector("[class*=tableHead] > [class*=description___]")
      description.style.maxWidth = description.scrollWidth - button.scrollWidth + "px"
    }

    setFfColHeader()

    parseTable(table)

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

    startLoop()
  })

  function parseTable(table) {
    parseRows(table.children)

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

    function parseRows(rows) {
      for(const row of rows) {
        if(row.classList.contains("tornPreloader"))
          continue

        const id = getId(row)
        const target = targets[id]
        const level_from_page = Number(row.querySelector("[class*='level___']").textContent)
        const status_from_page = row.querySelector("[class*='status___'] > span").textContent

        reworkButtons()

        new MutationObserver(records =>
          records.forEach(r =>
            r.addedNodes.forEach(n => {
              if(n.className.includes("buttonsGroup")) reworkButtons()
        })))
        .observe(row, {childList: true})

        if(target?.timestamp + INVALIDATION_TIME > Date.now() && status_from_page === target?.status) {
          redrawStatus(row)
          updateStatus(row, target.timestamp + stale_time)
        } else {
          targets[id] = {level: level_from_page, status: status_from_page}
          if(status_from_page === "Hospital")
            updateUntilHospitalized(row)
          else
            updateStatus(row)
        }

        if(target?.fair_fight?.last_updated > target?.last_action)
          redrawFf(row)
        else
          updateFf(row)

        function reworkButtons() {
          const buttons_group = row.querySelector("[class*='buttonsGroup']")
          if(!buttons_group)
            return

          const sample_button = buttons_group.querySelector("button")
          const disabled_button = buttons_group.querySelector("[class*='disabled___']")
          const refresh_button = document.createElement("button")

          sample_button.classList.forEach(c => {
            if(c.charAt(0) !== 'd')
              refresh_button.classList.add(c)
          })

          const refresh_icon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
          refresh_icon.setAttribute("width", 16)
          refresh_icon.setAttribute("height", 15)
          refresh_icon.setAttribute("viewBox", "0 0 16 15")

          const refresh_icon_path = document.createElementNS("http://www.w3.org/2000/svg", "path")
          refresh_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")

          refresh_icon.append(refresh_icon_path)
          refresh_button.appendChild(refresh_icon)

          if(sample_button.getAttribute("data-is-tooltip-opened") == null) {
            refresh_button.append(document.createTextNode("Refresh"))
          } else {
            const description = row.querySelector("[class*=description___]")
            description.style.maxWidth = description.scrollWidth - sample_button.scrollWidth + "px"
          }

          buttons_group.prepend(refresh_button)

          refresh_button.addEventListener("click", () => updateStatus(row, Date.now(), true))
          buttons_group.modified = true


          if(!disabled_button) {
            buttons_group.querySelector("a").addEventListener("click", () => attacked_targets.push(row))
            return
          }
          const a = document.createElement("a")
          a.href = `/loader2.php?sid=getInAttack&user2ID=${id}`
          disabled_button.childNodes.forEach(n => a.appendChild(n))
          disabled_button.classList.forEach(c => {
            if(c.charAt(0) !== 'd'){
              a.classList.add(c)}
          })
          disabled_button.parentNode.insertBefore(a, disabled_button)
          disabled_button.parentNode.removeChild(disabled_button)
          a.addEventListener("click",  () => attacked_targets.push(row))
        }
      }

      profile_updates.sort(prioritizeUpdates)

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

        function updateValue(row) {
          const target = targets[getId(row)]
          if(!target?.timestamp || target.timestamp + INVALIDATION_TIME < Date.now())
            return Infinity

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

          return target.timestamp
        }
      }
    }
  }

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

    setStatus()

    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
        updateUntilHospitalized(row)
      else
        updateStatus(row,  target.hospital + OUT_OF_HOSP)

      /* 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)
          }
        })
    }

    // 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

    const text_element = row.querySelector("[class*='level___']")
    const respect = (1 + 0.005 * target.level) * Math.min(3, ff)
    if(show_respect == 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 updateStatus(row, when, fast_track) {
    log(Debug.UPDATE, "Going to update", getName(row), "at", new Date(when).toLocaleTimeString())
    const requested_at = Date.now()
    const id = getId(row)
    if(fast_track && !row.fast_tracked) {
      row.updating = true
      row.fast_tracked = true
      profile_updates.unshift(row)
      return
    }
    setTimeout(() => {
      if(row.updating || targets[id]?.timestamp > requested_at) {
        if(row.updating)
          log(Debug.UPDATE, "Already marked for update", getName(row))
        else
          log(Debug.UPDATE, "Already updated", getName(row))
        return
      }

      row.updating = true
        profile_updates.push(row)
    }, when - Date.now())
  }

  function updateFf(row) {
    /**
     *    UseTornPal |  can_tornpal  |  torntools  | case | action
     *   ------------+---------------+-------------+------+--------
     *           YES |           YES |         N/A |    a | ff_updates.push
     *           YES |            NO |         YES |    e | try_tt (error when can_tornpal got set), fail silently
     *           YES |            NO |          NO |    b | fail silently (error whet can_tornpal got set)
     *            NO |           N/A |         YES |    d | try_tt, fail with error
     *            NO |           N/A |          NO |    b | fail silently (warn when torntools got set)
     *   WAIT_FOR_TT |           YES |         YES |    c | try_tt catch ff_updates.push
     *   WAIT_FOR_TT |           YES |          NO |    a | ff_updates.push
     *   WAIT_FOR_TT |            NO |         YES |    d | try_tt, fail with error
     *   WAIT_FOR_TT |            NO |          NO |    b | fail silently (error when can_tornpal got set)
     **/
    /** Case a - Only TornPal **/
    if((use_tornpal == UseTornPal.YES && can_tornpal)
       || (use_tornpal == UseTornPal.WAIT_FOR_TT && can_tornpal && !torntools)
    ) {
      ff_updates.push(row)
      return
    }

    /** Case b - Neither TornPal nor Torntools **/
    if(!torntools)
      return

    waitForElement(".tt-ff-scouter-indicator", row)
    .catch(function noTtFound(e) {
      /** Case c - TornTools failed so try TornPal next **/
      if(use_tornpal == UseTornPal.WAIT_FOR_TT && can_tornpal)
        ff_updates.push(row)
      /** Case d - TornTools failed but TornPal cannot be used**/
      else if(use_tornpal == UseTornPal.NO || use_tornpal == UseTornPal.WAIT_FOR_TT)
        throw new Error("Cannot find fair fight estimation from tornpal or from torntools.")
      /** Case e - User has enabled TornPal but it failed already, likely because TornTools is not installed, but we tried it anyway. **/
    })
    .then(function ffFromTt(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
      const id = getId(row)
      Object.assign(targets[getId(row)], {fair_fight: {value: ff}})
      redrawFf(row)
    })
  }

  function updateUntilHospitalized(row, time_out_after = INVALIDATION_TIME) {
    const id = getId(row)
    const start = Date.now()
    updateStatus(row)
    const attack_updater = setInterval(
      function attackUpdater() {
        updateStatus(row)
        if((targets[id]?.hospital > Date.now()) || Date.now() > start + time_out_after) {
          clearInterval(attack_updater)
          return
        }
      }, polling_interval)
  }

  function startLoop() {
    const loop_id = crypto.randomUUID()
    let idle_start = undefined
    let run_if_locked = []
    GM_setValue("main-loop", loop_id)
    GM_setValue("has-lock", loop_id)

    addEventListener("focus", function refocus() {
      GM_setValue("main-loop", loop_id)
      while(attacked_targets.length > 0)
        updateUntilHospitalized(attacked_targets.pop(), DEF_NOT_HOSPITAL)
    })

    setInterval(mainLoop, polling_interval)

    function mainLoop() {
      const jobs_waiting = profile_updates.length > 0 || ff_updates.length > 0 || run_if_locked.length > 0
      let has_lock = GM_getValue("has-lock")

      if(jobs_waiting && has_lock != loop_id && (has_lock === undefined || GM_getValue("main-loop") == loop_id)) {
        GM_setValue("has-lock", loop_id)
        log(Debug.API_LOOP, loop_id, "Setting lock and unpausing")
        has_lock = loop_id

        Object.assign(targets, GM_getValue("targets", {}))
        profile_updates =
          profile_updates
          .filter(row => {
            const t = targets[getId(row)]
            if(!t?.timestamp || t.timestamp < idle_start)
              return true
            finishUpdate(row)
            return false
          })
      } else if(!jobs_waiting && has_lock == loop_id) {
        GM_setValue("has-lock", undefined)
        log(Debug.API_LOOP, loop_id, "Releasing lock")
        has_lock = undefined
      }

      if(has_lock != loop_id) {
        log(Debug.API_LOOP, loop_id, "Idling")
        idle_start = Date.now()
        return
      }

      log(Debug.API_LOOP, loop_id, "Running")
      while(run_if_locked.length > 0)
        run_if_locked.pop()()

      if(api_key.charAt(0) === "#")
        return

      /**
       *
       * TornPal updates
       *
       **/
      if(ff_updates.length > 0) {
        const scouts = ff_updates.splice(0,250)
        GM_xmlhttpRequest({
          url: `https://tornpal.com/api/v1/ffscoutergroup?comment=targetlisthelper&key=${api_key}&targets=${scouts.map(getId).join(",")}`,

          onload: function updateFf({responseText}) {

            const r = JSON.parse(responseText)

            if(!r.status) {
              if(r.error_code == 772) {
                can_tornpal = false
                if(!torntools)
                  show_respect = Show.RESP_UNAVAILABLE
              }
              throw new Error("TornPal error: " + r.message)
            }

            run_if_locked.push(() => {
              Object.values(r.results)
              .forEach(({result}) => {
                if(result.status)
                  targets[result.player_id].fair_fight = {last_updated: result.last_updated, value: result.value}
              })

              GM_setValue("targets", targets)

              setTimeout(() => {
                scouts.forEach(row => {
                  if(targets[getId(row)].fair_fight)
                    redrawFf(row)
                })
              })
            })
          }
        })
      }

      /**
       *
       * Torn profile updates
       *
       **/
      let row
      while(profile_updates.length > 0 && !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: function updateProfile({responseText}) {
          let r = undefined
          try {
            r = JSON.parse(responseText) // Can also throw on malformed response
            if(r.error)
              throw new Error("Torn error: " + r.error.error)
          } catch (e) {
            profile_updates.unshift(row) // Oh Fuck, Put It Back In
            throw e
          }

          const response_date = Date.now()

          run_if_locked.push(() => {
            if(targets[id]?.timestamp < response_date) {
              Object.assign(targets[id], {
                timestamp: response_date,
                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)
            }
            finishUpdate(row)
          })
        }
      })

      function finishUpdate(row) {
        row.updating = false
        row.fast_tracked = false

        setTimeout(() => {
          row.classList.add('flash_green');
          setTimeout(() => row.classList.remove('flash_green'), 500)

          redrawStatus(row)
          updateStatus(row, targets[getId(row)].timestamp + stale_time)
        })
      }
    }
  }

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

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

  function setFfColHeader() {
    document
    .querySelector("[class*='level___'] > button")
    .childNodes[0]
    .data = show_respect == Show.RESPECT ? "R" : "Lvl"
  }

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

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

          clearInterval(interval)

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

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

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

  /** Ugly as fuck because we cant save what cant be stringified :/ **/
  function loadEnum(the_enum, loaded_value) {
    for(const [key,value] of Object.entries(the_enum)) {
      if(value === loaded_value)
        return the_enum[key]
    }
    return undefined
  }

  function log(type, ...message) {
    if(true)
      return
    else if(type == Debug.API_LOOP)
      console.log(new Date().toLocaleTimeString(), ...message)/**/
    else if(type == Debug.UPDATE)
      console.log(new Date().toLocaleTimeString(), ...message)/**/
  }
})()}