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