Fast bulk revive availability scanner for Torn war pages
// ==UserScript==
// @name Torn - Revive Intel Pro
// @namespace torn-revive-business
// @version 2.1.0
// @description Fast bulk revive availability scanner for Torn war pages
// @author TheBeastMan
// @license MIT
// @match https://www.torn.com/*
// @match https://torn.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect api.torn.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// CONFIG
// ============================================================
const API = 'https://api.torn.com/v2';
const VERSION = '2.1';
let apiKey = GM_getValue('reviveCheckerApiKey', '');
let scanning = false;
let lastScanTime = null;
let autoScanDone = false;
/*
All revive information from the factions is stored here.
player ID -> true / false
*/
const reviveCache = new Map();
/*
Faction ID -> member Map
*/
const factionCache = new Map();
// ============================================================
// CSS
// ============================================================
GM_addStyle(`
:root {
--ri-bg1: rgba(24, 27, 33, 0.985);
--ri-bg2: rgba(12, 14, 18, 0.985);
--ri-border: rgba(255,255,255,0.09);
--ri-text: #f3f5f8;
--ri-muted: #808793;
--ri-green: #4de58a;
--ri-green-bg: rgba(77,229,138,0.11);
--ri-red: #ff646e;
--ri-red-bg: rgba(255,100,110,0.11);
--ri-blue: #70aaff;
}
/* ========================================================
PANEL
======================================================== */
#ri-panel {
position: fixed;
right: 20px;
top: 140px;
width: 294px;
z-index: 999999;
color: var(--ri-text);
background:
linear-gradient(
145deg,
var(--ri-bg1),
var(--ri-bg2)
);
border:
1px solid var(--ri-border);
border-radius: 14px;
box-shadow:
0 20px 55px rgba(0,0,0,0.50),
0 3px 12px rgba(0,0,0,0.30),
inset 0 1px 0 rgba(255,255,255,0.04);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Arial,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
/* ========================================================
HEADER
======================================================== */
#ri-header {
height: 52px;
display: flex;
align-items: center;
padding:
0 10px 0 12px;
border-bottom:
1px solid var(--ri-border);
cursor: grab;
touch-action: none;
}
#ri-header.dragging {
cursor: grabbing;
}
#ri-logo {
width: 30px;
height: 30px;
position: relative;
flex-shrink: 0;
margin-right: 9px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background:
rgba(112,170,255,0.11);
border:
1px solid rgba(112,170,255,0.23);
}
#ri-logo::before,
#ri-logo::after {
content: "";
position: absolute;
border-radius: 2px;
background: #82b4ff;
box-shadow:
0 0 7px rgba(112,170,255,0.35);
}
#ri-logo::before {
width: 15px;
height: 5px;
}
#ri-logo::after {
width: 5px;
height: 15px;
}
#ri-title-area {
flex: 1;
min-width: 0;
}
#ri-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.35px;
}
#ri-subtitle {
margin-top: 1px;
color: #7d8490;
font-size: 8px;
letter-spacing: 0.65px;
text-transform: uppercase;
}
.ri-head-btn {
width: 29px;
height: 29px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #939aa5;
cursor: pointer;
font-size: 15px;
}
.ri-head-btn:hover {
color: white;
background:
rgba(255,255,255,0.055);
border-color:
rgba(255,255,255,0.06);
}
/* ========================================================
BODY
======================================================== */
#ri-body {
padding: 11px;
}
/* ========================================================
STATUS BAR
======================================================== */
#ri-status {
height: 37px;
display: flex;
align-items: center;
padding: 0 10px;
margin-bottom: 9px;
border-radius: 9px;
border:
1px solid rgba(255,255,255,0.055);
background:
rgba(255,255,255,0.032);
}
#ri-dot {
width: 7px;
height: 7px;
flex-shrink: 0;
margin-right: 8px;
border-radius: 50%;
background: #707782;
}
#ri-dot.ready {
background: var(--ri-green);
box-shadow:
0 0 8px rgba(77,229,138,0.5);
}
#ri-dot.scanning {
background: var(--ri-blue);
box-shadow:
0 0 9px rgba(112,170,255,0.55);
animation:
riPulse 1s infinite ease-in-out;
}
#ri-dot.error {
background: var(--ri-red);
box-shadow:
0 0 8px rgba(255,100,110,0.5);
}
@keyframes riPulse {
0% {
opacity: .4;
transform: scale(.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: .4;
transform: scale(.8);
}
}
#ri-status-text {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: #b7bdc6;
font-size: 10px;
}
/* ========================================================
FAST MODE
======================================================== */
#ri-mode {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 9px;
padding:
7px 9px;
border:
1px solid rgba(112,170,255,0.10);
border-radius: 8px;
background:
rgba(112,170,255,0.045);
}
#ri-mode-left {
color: #9299a4;
font-size: 9px;
letter-spacing: .2px;
}
#ri-mode-value {
display: flex;
align-items: center;
gap: 5px;
color: #86b7ff;
font-size: 9px;
font-weight: 700;
}
#ri-mode-value::before {
content: "";
width: 5px;
height: 5px;
border-radius: 50%;
background: #70aaff;
box-shadow:
0 0 6px rgba(112,170,255,.6);
}
/* ========================================================
COUNTERS
======================================================== */
#ri-counters {
display: grid;
grid-template-columns:
repeat(3, 1fr);
gap: 7px;
margin-bottom: 9px;
}
.ri-counter {
padding:
9px 5px 8px;
text-align: center;
border:
1px solid rgba(255,255,255,0.052);
border-radius: 9px;
background:
rgba(255,255,255,0.033);
}
.ri-counter-num {
font-size: 18px;
font-weight: 700;
line-height: 20px;
font-variant-numeric:
tabular-nums;
}
.ri-counter-label {
margin-top: 3px;
color: #737b86;
font-size: 8px;
font-weight: 600;
letter-spacing: .65px;
text-transform: uppercase;
}
#ri-count-on {
color: var(--ri-green);
}
#ri-count-off {
color: var(--ri-red);
}
#ri-count-total {
color: #dce0e5;
}
/* ========================================================
PROGRESS
======================================================== */
#ri-progress-track {
height: 3px;
margin:
2px 1px 10px;
overflow: hidden;
border-radius: 10px;
background:
rgba(255,255,255,0.06);
}
#ri-progress {
width: 0%;
height: 100%;
border-radius: inherit;
background:
linear-gradient(
90deg,
#568de7,
#80b9ff
);
transition:
width .22s ease;
}
/* ========================================================
SCAN BUTTON
======================================================== */
#ri-scan {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border:
1px solid rgba(112,170,255,.28);
border-radius: 9px;
color: #edf5ff;
background:
linear-gradient(
180deg,
rgba(74,126,207,.31),
rgba(47,86,146,.25)
);
cursor: pointer;
font-size: 11px;
font-weight: 700;
}
#ri-scan:hover {
border-color:
rgba(112,170,255,.52);
background:
linear-gradient(
180deg,
rgba(82,139,228,.39),
rgba(49,91,157,.31)
);
}
#ri-scan:disabled {
cursor: default;
opacity: .75;
}
#ri-scan-icon {
font-size: 15px;
}
#ri-scan.scanning #ri-scan-icon {
animation:
riSpin .75s linear infinite;
}
@keyframes riSpin {
to {
transform: rotate(360deg);
}
}
/* ========================================================
FOOTER
======================================================== */
#ri-footer {
display: flex;
justify-content: space-between;
margin-top: 9px;
padding:
0 2px;
color: #686f79;
font-size: 8px;
}
/* ========================================================
PLAYER BADGES
======================================================== */
.ri-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 5px;
padding:
1px 5px;
border-radius: 5px;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Arial,
sans-serif;
font-size: 9px;
line-height: 14px;
font-weight: 750;
letter-spacing: .35px;
vertical-align: middle;
white-space: nowrap;
}
.ri-badge::before {
content: "";
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 50%;
}
.ri-on {
color: #68e99b;
background:
var(--ri-green-bg);
border:
1px solid rgba(77,229,138,.20);
}
.ri-on::before {
background:
var(--ri-green);
box-shadow:
0 0 5px rgba(77,229,138,.45);
}
.ri-off {
color: #ff7f88;
background:
var(--ri-red-bg);
border:
1px solid rgba(255,100,110,.20);
}
.ri-off::before {
background:
var(--ri-red);
box-shadow:
0 0 5px rgba(255,100,110,.40);
}
/* ========================================================
SETTINGS
======================================================== */
#ri-menu {
position: absolute;
right: 8px;
top: 45px;
width: 178px;
display: none;
padding: 6px;
z-index: 1000001;
border:
1px solid rgba(255,255,255,.12);
border-radius: 10px;
background:
rgba(18,20,25,.99);
box-shadow:
0 12px 30px rgba(0,0,0,.45);
}
#ri-menu.open {
display: block;
}
.ri-menu-item {
width: 100%;
padding:
8px 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: #c5cad1;
font-size: 10px;
text-align: left;
cursor: pointer;
}
.ri-menu-item:hover {
color: white;
background:
rgba(255,255,255,.06);
}
.ri-danger {
color: #ff7c85;
}
.ri-divider {
height: 1px;
margin:
5px 4px;
background:
rgba(255,255,255,.07);
}
/* ========================================================
MINIMIZED
======================================================== */
#ri-panel.minimized {
width: 220px;
}
#ri-panel.minimized #ri-body {
display: none;
}
#ri-panel.minimized #ri-header {
border-bottom: none;
}
#ri-panel.minimized #ri-subtitle {
display: none;
}
/* ========================================================
TOAST
======================================================== */
#ri-toast {
position: absolute;
left: 50%;
bottom: -38px;
min-width: 180px;
padding:
7px 10px;
opacity: 0;
pointer-events: none;
transform:
translateX(-50%)
translateY(-5px);
border:
1px solid rgba(255,255,255,.09);
border-radius: 8px;
background:
rgba(18,20,25,.99);
color: #dce0e5;
box-shadow:
0 8px 22px rgba(0,0,0,.4);
font-size: 9px;
text-align: center;
transition:
opacity .16s ease,
transform .16s ease;
}
#ri-toast.show {
opacity: 1;
transform:
translateX(-50%)
translateY(0);
}
`);
// ============================================================
// BASIC HELPERS
// ============================================================
function setText(id, text) {
const element =
document.getElementById(id);
if (element)
element.textContent = text;
}
function setProgress(value) {
const element =
document.getElementById(
'ri-progress'
);
if (!element)
return;
value =
Math.max(
0,
Math.min(
100,
value
)
);
element.style.width =
value + '%';
}
function setStatus(
text,
type = 'ready'
) {
setText(
'ri-status-text',
text
);
const dot =
document.getElementById(
'ri-dot'
);
if (!dot)
return;
dot.className = type;
}
function formatTime(date) {
if (!date)
return 'Not scanned';
return date.toLocaleTimeString(
[],
{
hour: '2-digit',
minute: '2-digit'
}
);
}
// ============================================================
// TOAST
// ============================================================
let toastTimer = null;
function showToast(text) {
const toast =
document.getElementById(
'ri-toast'
);
if (!toast)
return;
toast.textContent =
text;
toast.classList.add(
'show'
);
clearTimeout(
toastTimer
);
toastTimer =
setTimeout(
() => {
toast.classList.remove(
'show'
);
},
2200
);
}
// ============================================================
// API KEY
// ============================================================
function setApiKey() {
const key =
prompt(
'Enter your Torn API key.\n\n' +
'The key is stored locally in Tampermonkey.'
);
if (!key)
return false;
apiKey =
key.trim();
GM_setValue(
'reviveCheckerApiKey',
apiKey
);
/*
Clear old faction information when key changes.
*/
factionCache.clear();
reviveCache.clear();
showToast(
'API key saved'
);
return true;
}
function clearApiKey() {
apiKey = '';
GM_setValue(
'reviveCheckerApiKey',
''
);
factionCache.clear();
reviveCache.clear();
removeBadges();
showToast(
'API key removed'
);
}
// ============================================================
// API
// ============================================================
function apiRequest(path) {
return new Promise(
(resolve, reject) => {
if (!apiKey) {
reject(
new Error(
'No API key configured'
)
);
return;
}
const separator =
path.includes('?')
? '&'
: '?';
const url =
API +
'/' +
path +
separator +
'key=' +
encodeURIComponent(
apiKey
);
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 12000,
onload: response => {
try {
const data =
JSON.parse(
response.responseText
);
if (data.error) {
reject(
new Error(
data.error.error ||
data.error.message ||
'Torn API error'
)
);
return;
}
resolve(data);
} catch (error) {
reject(error);
}
},
onerror: () => {
reject(
new Error(
'Network error'
)
);
},
ontimeout: () => {
reject(
new Error(
'API timeout'
)
);
}
});
}
);
}
// ============================================================
// PLAYER ID FROM PROFILE LINK
// ============================================================
function playerIdFromLink(link) {
if (!link)
return null;
try {
const href =
link.getAttribute('href') ||
link.href ||
'';
const match =
href.match(
/profiles\.php\?[^#]*XID=(\d+)/i
);
if (match)
return String(match[1]);
} catch (error) {}
return null;
}
// ============================================================
// FACTION ID FROM LINK
// ============================================================
function factionIdFromLink(link) {
if (!link)
return null;
try {
const href =
link.getAttribute('href') ||
link.href ||
'';
if (
!href.includes(
'factions.php'
)
)
return null;
const url =
new URL(
href,
location.origin
);
const id =
url.searchParams.get('ID') ||
url.searchParams.get('id');
if (
id &&
/^\d+$/.test(id)
)
return String(id);
} catch (error) {}
return null;
}
// ============================================================
// FIND ACTUAL WAR MEMBER LISTS
//
// IMPORTANT:
// We DO NOT scan every profile link on Torn anymore.
//
// Torn war scripts commonly use ul.members-list /
// .f-war-list.members-list.
// ============================================================
function getWarLists() {
const found =
new Set();
const selectors = [
'ul.members-list',
'.f-war-list.members-list',
'[class*="members-list"]'
];
selectors.forEach(
selector => {
document
.querySelectorAll(
selector
)
.forEach(
element => {
/*
Only accept it if it actually
contains player profile links.
*/
if (
element.querySelector(
'a[href*="profiles.php"][href*="XID="]'
)
) {
found.add(
element
);
}
}
);
}
);
return [
...found
];
}
// ============================================================
// GET WAR MEMBER LINKS ONLY
// ============================================================
function getWarMemberLinks() {
const links =
new Set();
getWarLists().forEach(
list => {
list
.querySelectorAll(
'a[href*="profiles.php"][href*="XID="]'
)
.forEach(
link => {
if (
playerIdFromLink(
link
)
) {
links.add(
link
);
}
}
);
}
);
return [
...links
];
}
// ============================================================
// UNIQUE WAR PLAYER IDS
// ============================================================
function getWarPlayerIds() {
const ids =
new Set();
getWarMemberLinks()
.forEach(
link => {
const id =
playerIdFromLink(
link
);
if (id)
ids.add(id);
}
);
return [
...ids
];
}
// ============================================================
// DISCOVER THE WAR FACTIONS
// ============================================================
function getWarFactionIds() {
const ids =
new Set();
/*
FIRST:
Look specifically inside each war member list.
*/
getWarLists().forEach(
list => {
list
.querySelectorAll(
'a[href*="factions.php"]'
)
.forEach(
link => {
const id =
factionIdFromLink(
link
);
if (id)
ids.add(id);
}
);
}
);
/*
SECOND:
Sometimes Torn places faction names directly above
the list instead of inside it.
Look around the war-area parents.
*/
if (
ids.size < 2
) {
getWarLists().forEach(
list => {
let parent =
list.parentElement;
for (
let depth = 0;
depth < 4 && parent;
depth++
) {
parent
.querySelectorAll(
'a[href*="factions.php"][href*="ID="]'
)
.forEach(
link => {
const id =
factionIdFromLink(
link
);
if (id)
ids.add(id);
}
);
parent =
parent.parentElement;
}
}
);
}
/*
THIRD:
Current faction URL can contain an ID.
*/
try {
const current =
new URL(
location.href
);
const id =
current.searchParams.get(
'ID'
);
if (
id &&
/^\d+$/.test(id)
) {
ids.add(
String(id)
);
}
} catch (error) {}
/*
FOURTH:
Fallback to faction links from Torn's main content.
This still does NOT inspect any user profiles.
*/
if (
ids.size < 2
) {
const main =
document.querySelector(
'#mainContainer'
) ||
document.querySelector(
'main'
) ||
document.body;
main
.querySelectorAll(
'a[href*="factions.php"][href*="ID="]'
)
.forEach(
link => {
const id =
factionIdFromLink(
link
);
if (id)
ids.add(id);
}
);
}
/*
A ranked war normally needs two factions.
Keep a small safety limit in case Torn page contains
unrelated faction links.
*/
return [
...ids
].slice(
0,
6
);
}
// ============================================================
// READ REVIVABLE VALUE
// ============================================================
function normalizeBool(value) {
if (
value === true ||
value === 1 ||
value === '1'
)
return true;
if (
value === false ||
value === 0 ||
value === '0'
)
return false;
return null;
}
function readRevivable(member) {
if (!member)
return null;
let value =
normalizeBool(
member.is_revivable
);
if (
value !== null
)
return value;
value =
normalizeBool(
member.revivable
);
return value;
}
// ============================================================
// NORMALIZE FACTION RESPONSE
// ============================================================
function normalizeMembers(data) {
if (!data)
return [];
let members =
data.members ||
data.faction_members ||
[];
/*
Current V2 normally uses an array.
*/
if (
Array.isArray(
members
)
) {
return members;
}
/*
Support object format just in case.
*/
if (
members &&
typeof members === 'object'
) {
return Object.entries(
members
)
.map(
([id, member]) => ({
...member,
id:
member.id ||
member.user_id ||
member.player_id ||
id
})
);
}
return [];
}
// ============================================================
// FETCH ENTIRE FACTION
//
// ONE REQUEST = ALL MEMBERS
//
// NO PLAYER PROFILE REQUESTS
// ============================================================
async function fetchFaction(
factionId
) {
const timestamp =
Math.floor(
Date.now() / 1000
);
const data =
await apiRequest(
`faction/${factionId}/members` +
`?striptags=true×tamp=${timestamp}`
);
const members =
normalizeMembers(
data
);
const memberMap =
new Map();
members.forEach(
member => {
const id =
member.id ||
member.user_id ||
member.player_id;
if (!id)
return;
const playerId =
String(id);
const revive =
readRevivable(
member
);
memberMap.set(
playerId,
revive
);
if (
revive !== null
) {
reviveCache.set(
playerId,
revive
);
}
}
);
factionCache.set(
String(factionId),
memberMap
);
return {
factionId:
String(factionId),
members:
members.length
};
}
// ============================================================
// BADGES
// ============================================================
function removeBadges() {
document
.querySelectorAll(
'.ri-badge'
)
.forEach(
badge =>
badge.remove()
);
}
function addBadge(
link,
revivable
) {
if (
revivable !== true &&
revivable !== false
)
return;
/*
Prevent duplicate badges.
*/
const old =
link.parentElement
?.querySelector(
`.ri-badge[data-ri-player="${playerIdFromLink(link)}"]`
);
if (old)
old.remove();
const badge =
document.createElement(
'span'
);
const id =
playerIdFromLink(
link
);
badge.dataset.riPlayer =
id;
badge.className =
'ri-badge ' +
(
revivable
? 'ri-on'
: 'ri-off'
);
badge.textContent =
revivable
? 'ON'
: 'OFF';
badge.title =
revivable
? 'You can revive this player'
: 'You cannot revive this player';
link.insertAdjacentElement(
'afterend',
badge
);
}
// ============================================================
// PAINT ALL WAR MEMBERS
// ============================================================
function paintBadges() {
const links =
getWarMemberLinks();
links.forEach(
link => {
const id =
playerIdFromLink(
link
);
if (!id)
return;
if (
reviveCache.has(
id
)
) {
addBadge(
link,
reviveCache.get(
id
)
);
}
}
);
updateCounters();
}
// ============================================================
// COUNTERS
// ============================================================
function calculateCounts() {
const ids =
getWarPlayerIds();
let on = 0;
let off = 0;
ids.forEach(
id => {
const value =
reviveCache.get(id);
if (
value === true
)
on++;
if (
value === false
)
off++;
}
);
return {
on,
off,
total:
ids.length,
resolved:
on + off
};
}
function updateCounters() {
const counts =
calculateCounts();
setText(
'ri-count-on',
counts.on
);
setText(
'ri-count-off',
counts.off
);
setText(
'ri-count-total',
counts.total
);
return counts;
}
// ============================================================
// SCANNING UI
// ============================================================
function setScanningUI(active) {
const button =
document.getElementById(
'ri-scan'
);
if (!button)
return;
button.disabled =
active;
button.classList.toggle(
'scanning',
active
);
setText(
'ri-scan-icon',
active
? '↻'
: '↻'
);
setText(
'ri-scan-text',
active
? 'Bulk Scanning...'
: 'Rescan War'
);
}
// ============================================================
// FAST BULK SCAN
// ============================================================
async function scanPage(
automatic = false
) {
if (scanning)
return;
/*
Do not do anything unless actual war member
lists are present.
*/
const playerIds =
getWarPlayerIds();
if (
playerIds.length === 0
) {
setStatus(
'Waiting for war member list...',
'ready'
);
setProgress(0);
return;
}
if (!apiKey) {
if (automatic)
return;
if (
!setApiKey()
)
return;
}
const factionIds =
getWarFactionIds();
if (
factionIds.length === 0
) {
setStatus(
'Could not detect war factions',
'error'
);
showToast(
'No war faction IDs detected'
);
return;
}
scanning = true;
reviveCache.clear();
factionCache.clear();
removeBadges();
setScanningUI(
true
);
setStatus(
`Bulk loading ${factionIds.length} faction${factionIds.length === 1 ? '' : 's'}...`,
'scanning'
);
setProgress(
10
);
try {
/*
IMPORTANT SPEED CHANGE
All faction calls happen AT THE SAME TIME.
Example:
faction A -> 50 members
faction B -> 50 members
This is only two API requests.
NOT 100 player profile requests.
*/
const jobs =
factionIds.map(
id =>
fetchFaction(id)
);
const results =
await Promise.allSettled(
jobs
);
setProgress(
80
);
const successful =
results.filter(
result =>
result.status ===
'fulfilled'
);
const failed =
results.filter(
result =>
result.status ===
'rejected'
);
/*
Draw everyone from the bulk response.
*/
paintBadges();
const counts =
updateCounters();
setProgress(
100
);
lastScanTime =
new Date();
setText(
'ri-last-scan',
'Last scan ' +
formatTime(
lastScanTime
)
);
/*
Determine whether every visible war member
received an ON/OFF result.
*/
if (
counts.resolved ===
counts.total
) {
setStatus(
`Complete • ${counts.total}/${counts.total} members`,
'ready'
);
} else {
setStatus(
`${counts.resolved}/${counts.total} members resolved`,
failed.length
? 'error'
: 'ready'
);
}
showToast(
`${counts.on} ON • ${counts.off} OFF • ${counts.total} members`
);
console.log(
'[Revive Intel] Bulk scan complete',
{
factions:
factionIds,
successfulRequests:
successful.length,
failedRequests:
failed.length,
players:
counts.total,
resolved:
counts.resolved
}
);
} catch (error) {
console.error(
'[Revive Intel]',
error
);
setStatus(
'Bulk scan failed',
'error'
);
showToast(
error.message ||
'Scan failed'
);
} finally {
scanning = false;
setScanningUI(
false
);
}
}
// ============================================================
// PANEL
// ============================================================
function createPanel() {
if (
document.getElementById(
'ri-panel'
)
)
return;
const panel =
document.createElement(
'div'
);
panel.id =
'ri-panel';
panel.innerHTML = `
<div id="ri-header">
<div id="ri-logo"></div>
<div id="ri-title-area">
<div id="ri-title">
Revive Intel
</div>
<div id="ri-subtitle">
War Availability Scanner
</div>
</div>
<button
id="ri-settings"
class="ri-head-btn"
type="button"
title="Settings"
>
⚙
</button>
<button
id="ri-minimize"
class="ri-head-btn"
type="button"
title="Minimize"
>
−
</button>
</div>
<div id="ri-menu">
<button
id="ri-set-key"
class="ri-menu-item"
type="button"
>
Set / Change API Key
</button>
<button
id="ri-reset-position"
class="ri-menu-item"
type="button"
>
Reset Panel Position
</button>
<div class="ri-divider"></div>
<button
id="ri-clear-key"
class="ri-menu-item ri-danger"
type="button"
>
Remove API Key
</button>
</div>
<div id="ri-body">
<div id="ri-status">
<div
id="ri-dot"
class="ready"
></div>
<div id="ri-status-text">
Ready
</div>
</div>
<div id="ri-mode">
<span id="ri-mode-left">
Scan engine
</span>
<span id="ri-mode-value">
FAST BULK MODE
</span>
</div>
<div id="ri-counters">
<div class="ri-counter">
<div
id="ri-count-on"
class="ri-counter-num"
>
0
</div>
<div class="ri-counter-label">
ON
</div>
</div>
<div class="ri-counter">
<div
id="ri-count-off"
class="ri-counter-num"
>
0
</div>
<div class="ri-counter-label">
OFF
</div>
</div>
<div class="ri-counter">
<div
id="ri-count-total"
class="ri-counter-num"
>
0
</div>
<div class="ri-counter-label">
Members
</div>
</div>
</div>
<div id="ri-progress-track">
<div id="ri-progress"></div>
</div>
<button
id="ri-scan"
type="button"
>
<span id="ri-scan-icon">
↻
</span>
<span id="ri-scan-text">
Rescan War
</span>
</button>
<div id="ri-footer">
<span id="ri-last-scan">
Not scanned yet
</span>
<span>
v${VERSION}
</span>
</div>
</div>
<div id="ri-toast"></div>
`;
document.body.appendChild(
panel
);
restorePanelPosition(
panel
);
setupControls();
makeDraggable(
panel
);
const savedMinimized =
GM_getValue(
'riPanelMinimized',
false
);
if (savedMinimized) {
panel.classList.add(
'minimized'
);
setText(
'ri-minimize',
'+'
);
}
}
// ============================================================
// CONTROLS
// ============================================================
function setupControls() {
document
.getElementById(
'ri-scan'
)
.addEventListener(
'click',
() => scanPage(false)
);
const menu =
document.getElementById(
'ri-menu'
);
const settings =
document.getElementById(
'ri-settings'
);
settings.addEventListener(
'click',
event => {
event.stopPropagation();
menu.classList.toggle(
'open'
);
}
);
document.addEventListener(
'click',
event => {
if (
!menu.contains(
event.target
) &&
event.target !==
settings
) {
menu.classList.remove(
'open'
);
}
}
);
document
.getElementById(
'ri-set-key'
)
.addEventListener(
'click',
() => {
menu.classList.remove(
'open'
);
setApiKey();
}
);
document
.getElementById(
'ri-clear-key'
)
.addEventListener(
'click',
() => {
menu.classList.remove(
'open'
);
clearApiKey();
}
);
document
.getElementById(
'ri-reset-position'
)
.addEventListener(
'click',
() => {
menu.classList.remove(
'open'
);
resetPosition();
}
);
document
.getElementById(
'ri-minimize'
)
.addEventListener(
'click',
event => {
event.stopPropagation();
const panel =
document.getElementById(
'ri-panel'
);
const minimized =
panel.classList.toggle(
'minimized'
);
event.currentTarget.textContent =
minimized
? '+'
: '−';
GM_setValue(
'riPanelMinimized',
minimized
);
}
);
}
// ============================================================
// DRAG PANEL
// ============================================================
function makeDraggable(panel) {
const header =
document.getElementById(
'ri-header'
);
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
header.addEventListener(
'pointerdown',
event => {
if (
event.target.closest(
'button'
)
)
return;
if (
event.pointerType ===
'mouse' &&
event.button !== 0
)
return;
dragging = true;
const rect =
panel.getBoundingClientRect();
startX =
event.clientX;
startY =
event.clientY;
startLeft =
rect.left;
startTop =
rect.top;
panel.style.right =
'auto';
panel.style.left =
startLeft + 'px';
panel.style.top =
startTop + 'px';
header.classList.add(
'dragging'
);
try {
header.setPointerCapture(
event.pointerId
);
} catch (error) {}
}
);
header.addEventListener(
'pointermove',
event => {
if (!dragging)
return;
let left =
startLeft +
(
event.clientX -
startX
);
let top =
startTop +
(
event.clientY -
startY
);
const maxLeft =
Math.max(
0,
window.innerWidth -
panel.offsetWidth
);
const maxTop =
Math.max(
0,
window.innerHeight -
panel.offsetHeight
);
left =
Math.min(
Math.max(
0,
left
),
maxLeft
);
top =
Math.min(
Math.max(
0,
top
),
maxTop
);
panel.style.left =
left + 'px';
panel.style.top =
top + 'px';
}
);
function finish(event) {
if (!dragging)
return;
dragging = false;
header.classList.remove(
'dragging'
);
try {
header.releasePointerCapture(
event.pointerId
);
} catch (error) {}
savePosition();
}
header.addEventListener(
'pointerup',
finish
);
header.addEventListener(
'pointercancel',
finish
);
}
// ============================================================
// POSITION
// ============================================================
function savePosition() {
const panel =
document.getElementById(
'ri-panel'
);
if (!panel)
return;
const rect =
panel.getBoundingClientRect();
GM_setValue(
'riPanelPosition',
{
left:
Math.round(
rect.left
),
top:
Math.round(
rect.top
)
}
);
}
function restorePanelPosition(panel) {
const saved =
GM_getValue(
'riPanelPosition',
null
);
if (
!saved ||
typeof saved.left !==
'number' ||
typeof saved.top !==
'number'
)
return;
panel.style.right =
'auto';
let left =
saved.left;
let top =
saved.top;
const maxLeft =
Math.max(
0,
window.innerWidth -
panel.offsetWidth
);
const maxTop =
Math.max(
0,
window.innerHeight -
panel.offsetHeight
);
left =
Math.min(
Math.max(
0,
left
),
maxLeft
);
top =
Math.min(
Math.max(
0,
top
),
maxTop
);
panel.style.left =
left + 'px';
panel.style.top =
top + 'px';
}
function resetPosition() {
GM_setValue(
'riPanelPosition',
null
);
const panel =
document.getElementById(
'ri-panel'
);
if (!panel)
return;
panel.style.left =
'auto';
panel.style.right =
'20px';
panel.style.top =
'140px';
showToast(
'Panel position reset'
);
}
// ============================================================
// DYNAMIC TORN PAGE WATCHER
//
// Torn dynamically redraws its member rows.
//
// We DO NOT make another API request.
// We simply reapply badges from the already downloaded cache.
// ============================================================
let mutationTimer = null;
const observer =
new MutationObserver(
() => {
clearTimeout(
mutationTimer
);
mutationTimer =
setTimeout(
() => {
const players =
getWarPlayerIds();
if (
players.length === 0
)
return;
/*
Newly rendered members immediately
receive their cached ON/OFF badge.
*/
paintBadges();
/*
Automatic initial bulk scan only once.
*/
if (
!autoScanDone &&
apiKey &&
!scanning
) {
autoScanDone =
true;
setTimeout(
() =>
scanPage(
true
),
400
);
}
},
250
);
}
);
// ============================================================
// MENU COMMANDS
// ============================================================
GM_registerMenuCommand(
'Rescan War Revives',
() => scanPage(false)
);
GM_registerMenuCommand(
'Set Torn API Key',
setApiKey
);
GM_registerMenuCommand(
'Clear Torn API Key',
clearApiKey
);
GM_registerMenuCommand(
'Reset Revive Intel Position',
resetPosition
);
// ============================================================
// START
// ============================================================
createPanel();
observer.observe(
document.body,
{
childList: true,
subtree: true
}
);
/*
Torn may already have loaded the war list before
MutationObserver starts.
*/
setTimeout(
() => {
const players =
getWarPlayerIds();
if (
players.length > 0
) {
updateCounters();
if (apiKey) {
autoScanDone =
true;
scanPage(
true
);
} else {
setStatus(
`${players.length} war members detected`,
'ready'
);
}
} else {
setStatus(
'Waiting for war member list...',
'ready'
);
}
},
1200
);
})();