Make FF visible, enable attack buttons, list target hp or remaining hosp time
// ==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.8 // @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) => {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) { 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") } })()}