Track Factions/Players with Torn Stats Spy Integration
// ==UserScript==
// @name Torn Faction & Player Tracker
// @namespace http://tampermonkey.net/
// @version 3.3
// @description Track Factions/Players with Torn Stats Spy Integration
// @author dingus
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect www.tornstats.com
// @connect api.torn.com
// ==/UserScript==
(function() {
'use strict';
let countdownInterval = null;
const logger = {
call: (type, url) => console.log(`%c🚀 API CALL [${type}]`, "background: #222; color: #ff922b; font-weight: bold; padding: 2px 5px; border-radius: 3px;", url),
return: (type, data) => console.log(`%c📥 RETURN [${type}]`, "background: #222; color: #51cf66; font-weight: bold; padding: 2px 5px;", data),
error: (msg, err) => console.error(`%c❌ ERROR: ${msg}`, "color: white; background: red; padding: 2px 5px;", err)
};
const getApiKey = () => GM_getValue('torn_api_key', '');
const saveApiKey = (key) => GM_setValue('torn_api_key', key);
const getTsKey = () => GM_getValue('ts_api_key', '');
const saveTsKey = (key) => GM_setValue('ts_api_key', key);
const getTrackedFactions = () => GM_getValue('trackedFactions', []);
const saveTrackedFactions = (list) => GM_setValue('trackedFactions', list);
const getTrackedPlayers = () => GM_getValue('trackedPlayers', []);
const saveTrackedPlayers = (list) => GM_setValue('trackedPlayers', list);
const getNetworthCache = () => GM_getValue('networthCache', {});
const saveNetworthCache = (cache) => GM_setValue('networthCache', cache);
const getWarCache = () => GM_getValue('warCache', {});
const saveWarCache = (cache) => GM_setValue('warCache', cache);
const getMaxHospTime = () => GM_getValue('maxHospTime', 999);
const saveMaxHospTime = (val) => GM_setValue('maxHospTime', val);
const getSpecialFilter = () => GM_getValue('specialFilter', false);
const saveSpecialFilter = (val) => GM_setValue('specialFilter', val);
const formatCurrency = (num) => '$' + String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const styleSheet = document.createElement("style");
styleSheet.innerText = `
#faction-tracker-panel {
position: fixed; top: 0; right: -100%; width: 100vw; max-width: 380px; height: 100%;
background: #1a1a1a; border-left: 2px solid #333; z-index: 999999;
transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); color: #fff; padding: 15px; overflow-y: auto; font-family: 'Segoe UI', Arial;
box-shadow: -5px 0 15px rgba(0,0,0,0.7); box-sizing: border-box;
}
#faction-tracker-panel.open { right: 0; }
.tracker-btn { margin: 5px 0; padding: 6px 12px; cursor: pointer; background: #333; border: 1px solid #444; color: #ccc; border-radius: 3px; font-size: 11px; font-weight: bold; text-transform: uppercase; border: 1px solid transparent; }
.tracker-btn:hover { background: #444; color: #fff; border-color: #666; }
.tracker-btn.active { background: #ff4757; color: #fff; border-color: #ff6b81; }
.member-row { border-bottom: 1px solid #222; padding: 12px 0; font-size: 12px; }
.status-red { color: #ff4757; font-weight: bold; font-family: monospace; font-size: 13px; }
.hosp-reason { color: #aaa; font-size: 11px; font-style: italic; margin-left: 5px; border-left: 1px solid #444; padding-left: 5px; }
.networth-tag { color: #ffa502; font-weight: bold; margin-left: 5px; font-size: 11px; }
.spy-tag { display: block; background: #2b2b2b; color: #70a1ff; font-family: monospace; font-size: 10px; padding: 4px; margin-top: 5px; border-radius: 2px; line-height: 1.4; border-left: 2px solid #1e90ff; }
.faction-header { background: #2f3542; padding: 8px; margin-top: 15px; font-weight: bold; display: flex; justify-content: space-between; border-radius: 3px; border-left: 3px solid #747d8c; }
.watchlist-header { background: #3c4453; color: #ffa502; border-left: 3px solid #ffa502; }
.remove-item { cursor: pointer; color: #ff4757; opacity: 0.6; font-size: 14px; }
.action-btns { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
.action-link { text-decoration: none; padding: 6px 12px; border-radius: 2px; font-size: 10px; font-weight: bold; color: #fff; transition: 0.2s; flex-grow: 1; text-align: center; }
.btn-profile { background: #2f3542; border: 1px solid #57606f; }
.btn-attack { background: #ff4757; border: 1px solid #ff6b81; }
.custom-input-field { background: #000; border: 1px solid #444; color: #fff; padding: 4px; border-radius: 3px; font-size: 12px; width: 100%; margin-bottom: 5px; font-family: monospace; }
#trak_hosp_99 { width: 45px; border-color: #ff4757; color: #ff4757; text-align: center; font-weight: bold; display: inline-block; }
.menu-section { background: #222; padding: 10px; border-radius: 4px; border: 1px solid #333; margin-bottom: 10px; }
`;
document.head.appendChild(styleSheet);
const panel = document.createElement('div');
panel.id = 'faction-tracker-panel';
panel.innerHTML = `
<h3 style="margin:0; color: #ff4757; letter-spacing: 1px;">HOSPITAL WATCH</h3>
<div class="menu-section" style="margin-top:15px;">
<div style="font-size:10px; color:#888; margin-bottom:5px; text-transform:uppercase; font-weight:bold;">API Configuration</div>
<input type="password" class="custom-input-field" id="trak_key_torn" name="trak_key_torn" placeholder="Torn Public Key" autocomplete="new-password" spellcheck="false">
<input type="password" class="custom-input-field" id="trak_key_stats" name="trak_key_stats" placeholder="Torn Stats API Key" autocomplete="new-password" spellcheck="false">
<div style="display:flex; flex-wrap:wrap; gap:4px;">
<button id="trak_save_btn" class="tracker-btn" style="flex:1 1 100%; margin:0; background:#ff4757; color:#fff;">Save All Keys</button>
<a href="https://www.torn.com/preferences.php#tab=api" target="_blank" class="tracker-btn" style="text-decoration:none; flex:1; text-align:center;">Get Torn Key</a>
<a href="https://www.tornstats.com/settings/general" target="_blank" class="tracker-btn" style="text-decoration:none; flex:1; text-align:center;">Get TS Key</a>
</div>
</div>
<div class="menu-section">
<div style="font-size:10px; color:#888; margin-bottom:5px; text-transform:uppercase; font-weight:bold;">Manual Entry (ID)</div>
<input type="text" class="custom-input-field" id="trak_manual_id" name="trak_manual_id" placeholder="Faction or Player ID" autocomplete="off" spellcheck="false">
<div style="display:flex; gap:5px;">
<button id="trak_add_fac_btn" class="tracker-btn" style="flex-grow:1; margin:0;">+ Faction</button>
<button id="trak_add_ply_btn" class="tracker-btn" style="flex-grow:1; margin:0;">+ Player</button>
</div>
</div>
<div style="margin: 5px 0; font-size: 11px; background: #222; padding: 10px; border-radius: 4px; border: 1px solid #333;">
🎯 TARGETS UNDER: <input type="text" id="trak_hosp_99" name="trak_hosp_99" maxlength="3" autocomplete="off"> MINS
<div style="margin-top: 10px;">
<button id="trak_spec_filt" class="tracker-btn" style="width: 100%; margin: 0;">🧪 Syrup/Blood Only</button>
</div>
</div>
<button id="trak_close" class="tracker-btn">Close</button>
<button id="trak_sync" class="tracker-btn">🔄 Sync</button>
<button id="trak_clear" class="tracker-btn" style="color:#ff4757; float:right;">Clear</button>
<hr style="border:0; border-top:1px solid #333; margin:10px 0;">
<div id="tracker-content"></div>
`;
document.body.appendChild(panel);
const toggleBtn = document.createElement('button');
toggleBtn.innerText = '🏥 Hosp Watch';
toggleBtn.className = 'tracker-btn';
toggleBtn.style = "position:fixed; top:60px; right:10px; z-index:99999; box-shadow:0 0 10px rgba(0,0,0,0.5); border: 1px solid #ff4757;";
document.body.appendChild(toggleBtn);
const loggedFetch = (type, url) => {
const key = getApiKey();
if (!key || key.length < 16) return Promise.resolve(null);
const finalUrl = url.includes('?') ? `${url}&key=${key}` : `${url}?key=${key}`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET", url: finalUrl,
onload: (res) => {
try { const data = JSON.parse(res.responseText); resolve(data); }
catch (e) { resolve(null); }
},
onerror: () => resolve(null)
});
});
};
const getSpyData = async (userID) => {
const tsKey = getTsKey();
if (!tsKey) return null;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: `https://www.tornstats.com/api/v1/${tsKey}/spy/${userID}`,
onload: (res) => {
try {
const r = JSON.parse(res.responseText);
if (r.spy && r.spy.status === true) {
resolve({
str: r.spy.strength?.toLocaleString() || "???",
def: r.spy.defense?.toLocaleString() || "???",
spd: r.spy.speed?.toLocaleString() || "???",
dex: r.spy.dexterity?.toLocaleString() || "???",
total: r.spy.total?.toLocaleString() || "???",
age: r.spy.difference || "Unknown age"
});
} else resolve(null);
} catch (e) { resolve(null); }
},
onerror: () => resolve(null)
});
});
};
const isFactionInWarCached = async (factionId) => {
const cache = getWarCache();
const now = Date.now();
if (cache[factionId] && (now - cache[factionId].timestamp < 1800000)) return cache[factionId].inWar;
const data = await loggedFetch("WAR_CHECK", `https://api.torn.com/v2/faction/${factionId}/rankedwars?offset=0&limit=1`);
const inWar = (data && data.rankedwars?.[0] && data.rankedwars[0].winner === null);
cache[factionId] = { inWar: inWar, timestamp: now };
saveWarCache(cache);
return inWar;
};
const getCachedNetworth = async (userId) => {
const cache = getNetworthCache();
const now = Date.now();
if (cache[userId] && (now - cache[userId].timestamp < 120000)) return cache[userId].value;
const data = await loggedFetch("NET_WORTH", `https://api.torn.com/v2/user/${userId}/personalstats?cat=networth`);
if (data && data.personalstats) {
const value = data.personalstats.networth.total;
cache[userId] = { value: value, timestamp: now };
saveNetworthCache(cache);
return value;
}
return null;
};
const startCountdown = () => {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const timers = document.querySelectorAll('.live-timer');
timers.forEach(timer => {
const until = parseInt(timer.dataset.until);
const diff = until - Math.floor(Date.now() / 1000);
if (diff <= 0) {
timer.innerHTML = "LANDING!";
timer.style.color = "#2ed573";
} else {
const m = Math.floor(diff / 60);
const s = diff % 60;
timer.innerHTML = `${m}m ${s}s`;
}
});
}, 1000);
};
const renderUserRow = async (m, maxMins, useSpecialFilter, specialReasons) => {
const remainingMins = Math.floor((m.status.until - Math.floor(Date.now() / 1000)) / 60);
if (remainingMins > maxMins && m.status.state === 'Hospital') return null;
if (useSpecialFilter && !specialReasons.includes(m.status.details)) return null;
const [nwValue, spy] = await Promise.all([getCachedNetworth(m.id), getSpyData(m.id)]);
const row = document.createElement('div');
row.className = 'member-row';
let spyHtml = "";
if (spy) {
spyHtml = `<div class="spy-tag">
STR: ${spy.str} | DEF: ${spy.def}<br>
SPD: ${spy.spd} | DEX: ${spy.dex}<br>
<strong>Total: ${spy.total}</strong> (${spy.age})
</div>`;
}
row.innerHTML = `
<div><strong>${m.name} [${m.id}]</strong> <span class="networth-tag">${nwValue ? formatCurrency(nwValue) : ""}</span></div>
<div class="${m.status.state === 'Hospital' ? 'status-red' : ''}" style="${m.status.state !== 'Hospital' ? 'color:#2ed573;' : ''}">
${m.status.state === 'Hospital' ? `<span class="live-timer" data-until="${m.status.until}">--m --s</span>` : 'OK'}
<span class="hosp-reason">${m.status.details || m.status.state}</span>
</div>
<div style="font-size:10px; color:#747d8c; margin-top:2px;">${m.status.description}</div>
${spyHtml}
<div class="action-btns">
<a href="https://www.torn.com/profiles.php?XID=${m.id}" target="_blank" class="action-link btn-profile">PROFILE</a>
<a href="https://www.torn.com/loader.php?sid=attack&user2ID=${m.id}" target="_blank" class="action-link btn-attack">ATTACK</a>
</div>
`;
return row;
};
const refreshPanel = async () => {
if (!getApiKey()) {
document.getElementById('tracker-content').innerHTML = '<p style="font-size:11px; color:#ff4757; text-align:center;">⚠️ TORN API KEY REQUIRED.</p>';
return;
}
const content = document.getElementById('tracker-content');
const factions = getTrackedFactions();
const players = getTrackedPlayers();
const maxMins = parseInt(getMaxHospTime()) || 999;
const useSpecialFilter = getSpecialFilter();
const specialReasons = ["Severe emesis following Ipecac Syrup ingestion", "Suffering from an acute hemolytic transfusion reaction"];
content.innerHTML = '<p style="font-size:12px; color: #888;">Syncing Data...</p>';
const container = document.createDocumentFragment();
if (players.length > 0) {
const watchDiv = document.createElement('div');
watchDiv.innerHTML = `<div class="faction-header watchlist-header"><span>INDIVIDUAL WATCHLIST</span></div>`;
for (const p of players) {
const pData = await loggedFetch("PLAYER_INFO", `https://api.torn.com/v2/user/${p.id}/profile`);
if (pData) {
const row = await renderUserRow({id: p.id, name: pData.name, status: pData.status}, 9999, useSpecialFilter, specialReasons);
if (row) {
row.firstChild.innerHTML += ` <span class="remove-item" data-remove-player="${p.id}" style="float:right;">❌</span>`;
watchDiv.appendChild(row);
}
}
}
container.appendChild(watchDiv);
}
for (const fac of factions) {
const factionDiv = document.createElement('div');
factionDiv.innerHTML = `<div class="faction-header"><span>${fac.name}</span><span class="remove-item" data-remove-fac="${fac.id}">❌</span></div>`;
if (await isFactionInWarCached(fac.id)) {
factionDiv.innerHTML += `<div style="padding:10px; color:#ffa502; text-align:center; font-size:11px;">Ranked War: Hidden</div>`;
} else {
const memData = await loggedFetch("MEMBERS", `https://api.torn.com/v2/faction/members?striptags=true&id=${fac.id}`);
if (memData && memData.members) {
const hosps = memData.members.filter(m => m.status.state === 'Hospital');
for (const m of hosps) {
const row = await renderUserRow(m, maxMins, useSpecialFilter, specialReasons);
if (row) factionDiv.appendChild(row);
}
}
}
container.appendChild(factionDiv);
}
content.innerHTML = '';
content.appendChild(container);
startCountdown();
};
const addFaction = async (id) => {
const data = await loggedFetch("FAC_INFO", `https://api.torn.com/v2/faction/${id}/basic`);
if (data && data.basic) {
let list = getTrackedFactions();
if (!list.find(f => f.id == id)) {
list.push({ id: id, name: data.basic.name });
saveTrackedFactions(list);
return true;
}
}
return false;
};
const addPlayer = async (id) => {
const data = await loggedFetch("PLAYER_INFO", `https://api.torn.com/v2/user/${id}/profile`);
if (data) {
let list = getTrackedPlayers();
if (!list.find(p => p.id == id)) {
list.push({ id: id, name: data.name });
saveTrackedPlayers(list);
return true;
}
}
return false;
};
const init = () => {
document.getElementById('trak_key_torn').value = getApiKey();
document.getElementById('trak_key_stats').value = getTsKey();
document.getElementById('trak_save_btn').onclick = () => {
const tKey = document.getElementById('trak_key_torn').value.trim();
const tsKey = document.getElementById('trak_key_stats').value.trim();
saveApiKey(tKey);
saveTsKey(tsKey);
alert("Keys Saved");
refreshPanel();
};
document.getElementById('trak_add_fac_btn').onclick = async () => {
const id = document.getElementById('trak_manual_id').value.trim();
if (id && await addFaction(id)) { document.getElementById('trak_manual_id').value = ''; refreshPanel(); }
};
document.getElementById('trak_add_ply_btn').onclick = async () => {
const id = document.getElementById('trak_manual_id').value.trim();
if (id && await addPlayer(id)) { document.getElementById('trak_manual_id').value = ''; refreshPanel(); }
};
const filterInput = document.getElementById('trak_hosp_99');
filterInput.value = getMaxHospTime();
filterInput.oninput = (e) => saveMaxHospTime(e.target.value.replace(/\D/g, ''));
const specialBtn = document.getElementById('trak_spec_filt');
if (getSpecialFilter()) specialBtn.classList.add('active');
specialBtn.onclick = () => {
const newState = !getSpecialFilter();
saveSpecialFilter(newState);
specialBtn.classList.toggle('active', newState);
refreshPanel();
};
const observer = new MutationObserver(() => {
const container = document.querySelector('.content-title');
if (container && !document.getElementById('add-to-tracker-btn')) {
const url = window.location.href;
const isProfile = url.includes('profiles.php');
const idMatch = url.match(/ID=(\d+)/) || url.match(/XID=(\d+)/);
if (idMatch) {
const id = idMatch[1];
const btn = document.createElement('button');
btn.id = 'add-to-tracker-btn';
btn.innerText = isProfile ? '➕ Track Player' : '➕ Track Faction';
btn.className = 'tracker-btn';
btn.style.marginLeft = '10px';
btn.onclick = async () => {
btn.innerText = '⌛...';
const success = isProfile ? await addPlayer(id) : await addFaction(id);
btn.innerText = success ? '✅ OK' : '⚠️ YES';
setTimeout(() => { btn.innerText = isProfile ? '➕ Track Player' : '➕ Track Faction'; refreshPanel(); }, 2000);
};
container.appendChild(btn);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
toggleBtn.onclick = () => { panel.classList.toggle('open'); if (panel.classList.contains('open')) refreshPanel(); };
document.getElementById('trak_close').onclick = () => panel.classList.remove('open');
document.getElementById('trak_sync').onclick = () => refreshPanel();
document.getElementById('trak_clear').onclick = () => { if(confirm("Clear All?")) { saveTrackedFactions([]); saveTrackedPlayers([]); refreshPanel(); }};
panel.addEventListener('click', (e) => {
if (e.target.dataset.removeFac) {
saveTrackedFactions(getTrackedFactions().filter(f => f.id != e.target.dataset.removeFac));
refreshPanel();
}
if (e.target.dataset.removePlayer) {
saveTrackedPlayers(getTrackedPlayers().filter(p => p.id != e.target.dataset.removePlayer));
refreshPanel();
}
});
};
init();
})();