// ==UserScript== // @name Torn War Stuff Enhanced // @namespace namespace // @version 1.12 // @description Show travel status and hospital time and sort by hospital time on war page. Fork of https://greasyfork.org/en/scripts/448681-torn-war-stuff // @author xentac // @license MIT // @match https://www.torn.com/factions.php* // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect api.torn.com // @downloadURL none // ==/UserScript== (async function () { ("use strict"); if (document.querySelector("div#FFScouterV2DisableWarMonitor")) { // We're already set up... return; } const ffScouterV2DisableWarMonitor = document.createElement("div"); ffScouterV2DisableWarMonitor.id = "FFScouterV2DisableWarMonitor"; ffScouterV2DisableWarMonitor.style.display = "none"; document.documentElement.appendChild(ffScouterV2DisableWarMonitor); let apiKey = localStorage.getItem("xentac-torn_war_stuff_enhanced-apikey") ?? "###PDA-APIKEY###"; const sort_enemies = true; let ever_sorted = false; const TRAVELING = "data-twse-traveling"; const HIGHLIGHT = "data-twse-highlight"; const STATUS_DIFFERS = "data-twse-status-differs"; try { GM_registerMenuCommand("Set Api Key", function () { checkApiKey(false); }); } catch (error) { // This is fine, but we need to handle torn pda too } function checkApiKey(checkExisting = true) { if ( !checkExisting || apiKey === null || apiKey.indexOf("PDA-APIKEY") > -1 || apiKey.length != 16 ) { let userInput = prompt( "Please enter a PUBLIC Api Key, it will be used to get basic faction information:", apiKey ?? "", ); if (userInput !== null && userInput.length == 16) { apiKey = userInput; localStorage.setItem( "xentac-torn_war_stuff_enhanced-apikey", userInput, ); } else { console.error( "[TornWarStuffEnhanced] User cancelled the Api Key input.", ); } } } GM_addStyle(` .members-list li:has(div.status[data-twse-highlight="true"]) { background-color: #99EB99 !important; } .members-list li:has(div.status[data-twse-status-differs="true"]) { background-color: #C4974C !important; } .members-list div.status[data-twse-traveling="true"]::after { color: #696026 !important; } :root .dark-mode .members-list li:has(div.status[data-twse-highlight="true"]) { background-color: #446944 !important; } :root .dark-mode .members-list li:has(div.status[data-twse-status-differs="true"]) { background-color: #795315 !important; } :root .dark-mode .members-list div.status[data-twse-traveling="true"]::after { color: #FFED76 !important; } `); GM_addStyle(` .members-list div.status { position: relative !important; color: transparent !important; } .members-list div.status::after { content: var(--twse-content); position: absolute; top: 0; left: 0; width: calc(100% - 10px); height: 100%; background: inherit; display: flex; right: 10px; justify-content: flex-end; align-items: center; } .members-list .ok.status::after { color: var(--user-status-green-color); } .members-list .not-ok.status::after { color: var(--user-status-red-color); } .members-list .abroad.status::after, .members-list .traveling.status::after { color: var(--user-status-blue-color); } `); let running = true; let found_war = false; let pageVisible = !document.hidden; document.addEventListener("visibilitychange", () => { pageVisible = !document.hidden; }); let member_lists = document.querySelectorAll("ul.members-list"); const refresh_member_lists = () => { member_lists = document.querySelectorAll("ul.members-list"); }; function get_faction_ids() { refresh_member_lists(); const nodes = get_member_lists(); const faction_ids = []; nodes.forEach((elem) => { const q = elem.querySelector(`A[href^='/factions.php']`); if (!q) { return; } const s = q.href.split("ID="); if (s.length <= 1) { return; } const id = s[1]; if (id) { faction_ids.push(id); } }); return faction_ids; } function get_member_lists() { return member_lists; } function get_sorted_column(member_list) { const member_div = member_list.parentNode.querySelector("div.member div"); const level_div = member_list.parentNode.querySelector("div.level div"); const points_div = member_list.parentNode.querySelector("div.points div"); const status_div = member_list.parentNode.querySelector("div.status div"); let column = null; let order = null; let classname = ""; if (member_div && member_div.className.match(/activeIcon__/)) { column = "member"; classname = member_div.className; } else if (level_div && level_div.className.match(/activeIcon__/)) { column = "level"; classname = level_div.className; } else if (points_div && points_div.className.match(/activeIcon__/)) { column = "points"; classname = points_div.className; } else if (status_div && status_div.className.match(/activeIcon__/)) { column = "status"; classname = status_div.className; } if (classname && classname.match(/asc__/)) { order = "asc"; } else { order = "desc"; } if (column != "score" && order != "desc") { ever_sorted = true; } return { column: column, order: order }; } function pad_with_zeros(n) { if (n < 10) { return "0" + n; } return n; } const member_status = new Map(); const member_lis = new Map(); let last_request = null; const MIN_TIME_SINCE_LAST_REQUEST = 10000; const description_cache = new Map(); const descriptions_observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.classList && node.classList.contains("descriptions")) { console.log("[TornWarStuffEnhanced] .descriptions added to DOM"); faction_war_observer.observe(node, { childList: true, subtree: true, }); // Check to see if the node already exists on add for (const child of node.childNodes) { faction_war_check(child); } } } for (const node of mutation.removedNodes) { if (node.classList && node.classList.contains("descriptions")) { console.log("[TornWarStuffEnhanced] .descriptions removed from DOM"); faction_war_observer.disconnect(); } } } }); const faction_war_check = (node) => { if (node.classList && node.classList.contains("faction-war")) { console.log( "[TornWarStuffEnhanced] Observed mutation of .faction-war node", ); found_war = true; extract_all_member_lis(); for (const faction_id of get_faction_ids()) { // If we have a cached value for the members, pre-populate them populate_cached_status(faction_id); } update_statuses(); faction_war_observer.disconnect(); } }; const faction_war_observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { faction_war_check(node); } } }); const found_faction_war_list = (factwarlist) => { console.log("[TornWarStuffEnhanced] Found #faction_war_list_id"); if (factwarlist.querySelector(".faction-war")) { console.log("[TornWarStuffEnhanced] .faction-war already exists"); found_war = true; extract_all_member_lis(); for (const faction_id of get_faction_ids()) { // If we have a cached value for the members, pre-populate them populate_cached_status(faction_id); } update_statuses(); return; } if (found_war) { return; } console.log("[TornWarStuffEnhanced] Adding descriptions observer"); descriptions_observer.observe(factwarlist, { childList: true }); const descriptions = factwarlist.querySelector(".descriptions"); if (descriptions) { console.log( "[TornWarStuffEnhanced] .descriptions already exists, adding .faction-war observer", ); faction_war_observer.observe(descriptions, { childList: true, subtree: true, }); } }; const document_observer = new MutationObserver(() => { const factwarlist = document.querySelector("#faction_war_list_id"); if (factwarlist) { console.log( "[TornWarStuffEnhanced] Found #faction_war_list_id within a mutation.", ); found_faction_war_list(factwarlist); document_observer.disconnect(); } }); // Check to see if #faction_war_list_id already exists const factwarlist = document.querySelector("#faction_war_list_id"); if (factwarlist) { console.log( "[TornWarStuffEnhanced] Found #faction_war_list_id in document already.", ); found_faction_war_list(factwarlist); document_observer.disconnect(); } // Observe all page changes till we find #faction_war_list_id document_observer.observe(document.body, { subtree: true, childList: true, }); console.log( "[TornWarStuffEnhanced] #faction_war_list_id observer installed.", ); // Wait 10 seconds and check one last time for #faction_war_list_id // Then remove the observer even if we don't find it setTimeout(() => { const factwarlist = document.querySelector("#faction_war_list_id"); if (factwarlist) { console.log( "[TornWarStuffEnhanced] Found #faction_war_list_id in document after 10 seconds.", ); found_faction_war_list(factwarlist); } document_observer.disconnect(); }, 10000); function cache_status(faction_id, status) { localStorage.setItem( `xentac-torn_war_stuff_enhanced-status-${faction_id}`, JSON.stringify({ timestamp: new Date().getTime(), status: status }), ); } function populate_cached_status(faction_id) { const status = get_cached_status(faction_id); if (!status) { return; } for (const [k, v] of Object.entries(status)) { member_status.set(k, v); } console.log( "[TornWarStuffEnhanced] Populated member_status with previously cached values", ); } function get_cached_status(faction_id) { const cache = localStorage.getItem( `xentac-torn_war_stuff_enhanced-status-${faction_id}`, ); if (!cache) { return null; } try { const parsed = JSON.parse(cache); const now = new Date().getTime(); if (parsed && now - parsed.timestamp > MIN_TIME_SINCE_LAST_REQUEST) { return null; } return parsed.status; } catch (e) { return null; } } function clean_cached_statuses() { const now = new Date().getTime(); let cleaned_count = 0; Object.keys(localStorage).forEach((key) => { if (!key.startsWith("xentac-torn_war_stuff_enhanced-status-")) { return; } const value = localStorage.getItem(key); try { const parsed = JSON.parse(value); if (now - parsed.timestamp > MIN_TIME_SINCE_LAST_REQUEST) { localStorage.removeItem(key); cleaned_count++; } } catch (e) { localStorage.removeItem(key); cleaned_count++; } }); console.log( `[TornWarStuffEnhanced] Cleaned ${cleaned_count} expired cached values`, ); } async function update_statuses() { if (!running) { return; } const faction_ids = get_faction_ids(); // If the faction ids are not yet available, give up and let us request again next time if (faction_ids.length == 0) { return; } if ( last_request && new Date() - last_request < MIN_TIME_SINCE_LAST_REQUEST ) { return; } last_request = new Date(); for (let i = 0; i < faction_ids.length; i++) { if (!update_status(faction_ids[i])) { return; } } } async function update_status(faction_id) { let error = false; const status = await fetch( `https://api.torn.com/faction/${faction_id}?selections=basic&key=${apiKey}&comment=TornWarStuffEnhanced`, ) .then((r) => r.json()) .catch((m) => { console.error("[TornWarStuffEnhanced] ", m); error = true; }); if (error) { return true; } if (status.error) { console.log( "[TornWarStuffEnhanced] Received error from torn API ", status.error, ); if ( [0, 1, 2, 3, 4, 6, 7, 10, 12, 13, 14, 16, 18, 21].includes(status.error) ) { console.log( "[TornWarStuffEnhanced] Received a non-recoverable error. Giving up.", ); running = false; return false; } if ([5, 8, 9].includes(status.error.code)) { // 5: Too many requests error code // 8: IP block // 9: API disabled // Try again in 30 + MIN_TIME_SINCE_LAST_REQUEST seconds console.log("[TornWarStuffEnhanced] Retrying in 40 seconds."); last_request = new Date() + 30000; } return false; } if (!status.members) { return false; } const req_time = Date.now(); const faction_status = {}; for (const [k, v] of Object.entries(status.members)) { const status = v.status; status.last_req_time = req_time; let d_cache = description_cache.get(status.description); if (!d_cache) { d_cache = status.description .replace("South Africa", "SA") .replace("Cayman Islands", "CI") .replace("United Kingdom", "UK") .replace("Argentina", "Arg") .replace("Switzerland", "Switz"); } status.description = d_cache; const prev = member_status.get(k); const prev_state = prev?.state ?? "Unknown"; const prev_since = prev?.since ?? req_time; const prev_traveling_error_bar = prev?.traveling_error_bar ?? 0; const prev_last_req_time = prev?.last_req_time; if (prev_state == status.state) { // If our previous state is the same as our current state, pass since and traveling_error_bars forward status.since = prev_since; status.traveling_error_bar = prev_traveling_error_bar; } else { // Otherwise set them new status.since = Date.now(); if (prev_state != "Traveling") { // If they weren't traveling before but are now // Set the error bar based on the last request (so we can maybe predict flight times) // TODO: This needs to be cached in local storage (and cleaned up) so that it persists over refreshes v.traveling_error_bar = Date.now() - (prev_last_req_time ?? 0); } } member_status.set(k, status); faction_status[k] = status; } cache_status(faction_id, faction_status); } function extract_all_member_lis() { member_lis.clear(); refresh_member_lists(); get_member_lists().forEach((ul) => { extract_member_lis(ul); }); } function extract_member_lis(ul) { const lis = ul.querySelectorAll("LI.enemy, li.your"); lis.forEach((li) => { const atag = li.querySelector(`A[href^='/profiles.php']`); if (!atag) { return; } const id = atag.href.split("ID=")[1]; member_lis.set(id, { li: li, div: li.querySelector("DIV.status"), }); }); } function queue_until(deferredWrites, node, until, dirty) { if (node.getAttribute("data-until") != until) { deferredWrites.push([node, "data-until", until]); return true; } return dirty; } function queue_since(deferredWrites, node, since, dirty) { if (node.getAttribute("data-since") != since) { deferredWrites.push([node, "data-since", since]); return true; } return dirty; } function queue_sort(deferredWrites, node, level, dirty) { if (node.getAttribute("data-sortA") != level) { deferredWrites.push([node, "data-sortA", level]); return true; } return dirty; } const TIME_BETWEEN_FRAMES = 500; const deferredWrites = []; function watch() { let dirtySort = false; deferredWrites.length = 0; member_lis.forEach((elem, id) => { const li = elem.li; if (!li) { return; } const status = member_status.get(id); const status_DIV = elem.div; if (!status_DIV) { return; } if (!status || !running) { // Make sure the user sees something before we've downloaded state status_DIV.style.setProperty( "--twse-content", `"${status_DIV.textContent}"`, ); return; } dirtySort = queue_until(deferredWrites, li, status.until, dirtySort); dirtySort = queue_since(deferredWrites, li, status.since, dirtySort); let data_location = ""; switch (status.state) { case "Abroad": case "Traveling": // API says they're traveling but site has updated. Trust the site and sort them to the top. if ( !( status_DIV.classList.contains("traveling") || status_DIV.classList.contains("abroad") ) ) { if (status_DIV.textContent == "Okay") { dirtySort = queue_sort(deferredWrites, li, 0, dirtySort); deferredWrites.push([status_DIV, STATUS_DIFFERS, "true"]); } status_DIV.style.setProperty( "--twse-content", `"${status_DIV.textContent}"`, ); break; } deferredWrites.push([ status_DIV, "data-traveling-error-bar", status.traveling_error_bar, ]); deferredWrites.push([status_DIV, STATUS_DIFFERS, "false"]); if (status.description.includes("Traveling to ")) { dirtySort = queue_sort(deferredWrites, li, 5, dirtySort); const content = "► " + status.description.split("Traveling to ")[1]; data_location = content; status_DIV.style.setProperty("--twse-content", `"${content}"`); } else if (status.description.includes("In ")) { dirtySort = queue_sort(deferredWrites, li, 4, dirtySort); const content = status.description.split("In ")[1]; data_location = content; status_DIV.style.setProperty("--twse-content", `"${content}"`); } else if (status.description.includes("Returning")) { dirtySort = queue_sort(deferredWrites, li, 3, dirtySort); const content = "◄ " + status.description.split("Returning to Torn from ")[1]; data_location = content; status_DIV.style.setProperty("--twse-content", `"${content}"`); } else if (status.description.includes("Traveling")) { dirtySort = queue_sort(deferredWrites, li, 6, dirtySort); const content = "Traveling"; data_location = content; status_DIV.style.setProperty("--twse-content", `"${content}"`); } break; case "Hospital": case "Jail": let now = new Date().getTime() / 1000; if (window.getCurrentTimestamp) { now = window.getCurrentTimestamp() / 1000; } const hosp_time_remaining = Math.round(status.until - now); if ( !( status_DIV.classList.contains("hospital") || status_DIV.classList.contains("jail") ) ) { // Catch the natural but special case where someone meds out. // Our API knowledge will be that they still have hospital time // but they will be Okay. if (hosp_time_remaining >= 0) { dirtySort = queue_sort(deferredWrites, li, 0, dirtySort); deferredWrites.push([status_DIV, STATUS_DIFFERS, "true"]); } status_DIV.style.setProperty( "--twse-content", `"${status_DIV.textContent}"`, ); deferredWrites.push([status_DIV, TRAVELING, "false"]); deferredWrites.push([status_DIV, HIGHLIGHT, "false"]); break; } deferredWrites.push([status_DIV, STATUS_DIFFERS, "false"]); dirtySort = queue_sort(deferredWrites, li, 2, dirtySort); if (status.description.includes("In a")) { deferredWrites.push([status_DIV, TRAVELING, "true"]); } else { deferredWrites.push([status_DIV, TRAVELING, "false"]); } if (hosp_time_remaining <= 0) { deferredWrites.push([status_DIV, HIGHLIGHT, "false"]); return; } const s = Math.floor(hosp_time_remaining % 60); const m = Math.floor((hosp_time_remaining / 60) % 60); const h = Math.floor(hosp_time_remaining / 60 / 60); const time_string = `${pad_with_zeros(h)}:${pad_with_zeros(m)}:${pad_with_zeros(s)}`; status_DIV.style.setProperty("--twse-content", `"${time_string}"`); if (hosp_time_remaining < 300) { deferredWrites.push([status_DIV, HIGHLIGHT, "true"]); } else { deferredWrites.push([status_DIV, HIGHLIGHT, "false"]); } break; default: status_DIV.style.setProperty( "--twse-content", `"${status_DIV.textContent}"`, ); dirtySort = queue_sort(deferredWrites, li, 1, dirtySort); deferredWrites.push([status_DIV, TRAVELING, "false"]); deferredWrites.push([status_DIV, HIGHLIGHT, "false"]); deferredWrites.push([status_DIV, STATUS_DIFFERS, "false"]); break; } if (li.getAttribute("data-location") != data_location) { deferredWrites.push([li, "data-location", data_location]); dirtySort = true; } }); for (const [elem, attrib, value] of deferredWrites) { elem.setAttribute(attrib, value); } deferredWrites.length = 0; if (sort_enemies && dirtySort) { // Only sort if Status is the field to be sorted const nodes = get_member_lists(); for (let i = 0; i < nodes.length; i++) { let sorted_column = get_sorted_column(nodes[i]); if (!ever_sorted) { sorted_column = { column: "status", order: "asc" }; } if (sorted_column["column"] != "status") { continue; } let lis = nodes[i].childNodes; let sorted_lis = Array.from(lis).sort((a, b) => { let left = a; let right = b; if (sorted_column["order"] == "desc") { left = b; right = a; } const sorta = left.getAttribute("data-sortA") - right.getAttribute("data-sortA"); if (sorta != 0) { return sorta; } const left_location = left.getAttribute("data-location"); const right_location = right.getAttribute("data-location"); if (left_location && right_location) { if (left_location < right_location) { return -1; } else if (left_location == right_location) { return 0; } else { return 1; } } const sort = left.getAttribute("data-sortA"); // Differs and Okay status sorts since oldest first if (sort === "0" || sort === "1") { return ( right.getAttribute("data-since") - left.getAttribute("data-since") ); // Hospital timers sort until soonest first } else { return ( left.getAttribute("data-until") - right.getAttribute("data-until") ); } }); let sorted = true; for (let j = 0; j < sorted_lis.length; j++) { if (nodes[i].children[j] !== sorted_lis[j]) { sorted = false; break; } } if (!sorted) { const fragment = document.createDocumentFragment(); sorted_lis.forEach((li) => { fragment.appendChild(li); }); nodes[i].appendChild(fragment); } } } for (const [id, ref] of member_lis) { if (!ref.li.isConnected) { member_lis.delete(id); } } } setInterval(() => { if (!running || !found_war) return; update_statuses(); }, MIN_TIME_SINCE_LAST_REQUEST); setInterval(() => { if (!found_war || !running || !pageVisible) { return; } watch(); }, TIME_BETWEEN_FRAMES); console.log("[TornWarStuffEnhanced] Initialized"); clean_cached_statuses(); window.dispatchEvent(new Event("FFScouterV2DisableWarMonitor")); })();