Tracks CPR data for organized crimes by intercepting Fetch requests, compatible with TornPDA and PC
// ==UserScript==
// @name Torn Stats Faction CPR Tracker
// @namespace http://tampermonkey.net/
// @version 1.4.0
// @description Tracks CPR data for organized crimes by intercepting Fetch requests, compatible with TornPDA and PC
// @author Allenone[2033011], IceBlueFire[776]
// @license MIT
// @match https://www.torn.com/factions.php?step=your*
// @run-at document-idle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect tornstats.com
// ==/UserScript==
(function() {
'use strict';
const TARGET_URL_BASE = 'page.php?sid=organizedCrimesData&step=crimeList';
const STORAGE_PREFIX = 'TSFactionCPRTracker_';
const isTornPDA = typeof window.flutter_inappwebview !== 'undefined';
// Storage functions (only used for API key now)
const getValue = isTornPDA
? (key, def) => JSON.parse(localStorage.getItem(key) || JSON.stringify(def))
: GM_getValue;
const setValue = isTornPDA
? (key, value) => localStorage.setItem(key, JSON.stringify(value))
: GM_setValue;
const deleteValue = isTornPDA
? (key) => localStorage.removeItem(key)
: GM_deleteValue;
// HTTP request function
const xmlhttpRequest = isTornPDA
? (details) => {
window.flutter_inappwebview.callHandler('PDA_httpPost', details.url, details.headers, details.data)
.then(response => {
details.onload({
status: response.status,
responseText: response.data
});
})
.catch(err => details.onerror(err));
}
: GM_xmlhttpRequest;
// API key handling
let API_KEY;
if (isTornPDA) {
API_KEY = "#############"; // Hardcoded for TornPDA. Set this to your Torn Stats API key.
setValue(`${STORAGE_PREFIX}api_key`, API_KEY);
} else {
API_KEY = getValue(`${STORAGE_PREFIX}api_key`, null);
if (!API_KEY) {
API_KEY = prompt('Please enter your Torn API key that you use with Torn Stats:');
if (!API_KEY) {
alert('Faction CPR Tracker: API key is required for functionality.');
return;
}
setValue(`${STORAGE_PREFIX}api_key`, API_KEY);
}
}
// Queue to store data captured while page was hidden
let pendingSubmission = null;
async function submitCPRData(apiKey, checkpointPassRates) {
// Only submit if page is visible
if (document.visibilityState === 'hidden') {
console.log('Page hidden, queuing CPR data for later submission');
pendingSubmission = { apiKey, checkpointPassRates };
return;
}
return new Promise((resolve, reject) => {
xmlhttpRequest({
method: 'POST',
url: 'https://www.tornstats.com/api/v2/' + apiKey + '/crime_pass_rates/store',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(checkpointPassRates),
onload: (response) => {
try {
const jsonResponse = JSON.parse(response.responseText);
console.log('Response JSON:', jsonResponse);
// Check for user-not-found error
if (jsonResponse.status === false && jsonResponse.message === 'ERROR: User not found.') {
console.warn('Invalid API key detected. Clearing stored key...');
deleteValue(`${STORAGE_PREFIX}api_key`);
showPopup('Error: API key invalid. Prompted for new key.', 'error');
const newKey = prompt("Your API key appears to be invalid or expired. Please enter a new Torn Stats API key:");
if (newKey) {
setValue(`${STORAGE_PREFIX}api_key`, newKey);
console.log('New API key saved. Please retry the action.');
}
reject(new Error('Invalid API key.'));
return;
}
if (jsonResponse.status === true && jsonResponse.successes) {
const changes = [];
let recordedCount = 0;
for (const crime in jsonResponse.successes) {
const roles = jsonResponse.successes[crime];
for (const role in roles) {
const serverMessage = roles[role];
if (serverMessage.includes('increased')) {
changes.push(`${crime} - ${role}: Increased!`);
} else if (serverMessage.includes('decreased')) {
changes.push(`${crime} - ${role}: Decreased`);
} else {
recordedCount++;
}
}
}
let popupMessage = 'CPR data submitted.';
let popupType = 'info';
if (changes.length > 0) {
popupMessage = `CPR Changes Detected:\n${changes.join('\n')}`;
popupType = 'success';
} else if (recordedCount > 0) {
popupMessage = `CPR data recorded. No changes detected.\n(${recordedCount} positions updated)`;
}
if (jsonResponse.errors && Object.keys(jsonResponse.errors).length > 0) {
const errorCount = Object.values(jsonResponse.errors)
.reduce((sum, obj) => sum + (typeof obj === 'object' ? Object.keys(obj).length : 1), 0);
popupMessage += `\n\n${errorCount} position(s) not found.`;
}
showPopup(popupMessage, popupType);
resolve();
return;
}
} catch (e) {
console.error('Could not parse JSON:', e);
}
console.error('API error:', response.status, response.responseText);
showPopup('CPR submission failed.', 'error');
reject(new Error('API error'));
},
onerror: (err) => {
console.error('Submission error:', err);
showPopup('CPR submission failed (network error).', 'error');
reject(err);
}
});
});
}
// Handle visibility change - submit pending data when page becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && pendingSubmission) {
console.log('Page visible, submitting queued CPR data');
const { apiKey, checkpointPassRates } = pendingSubmission;
pendingSubmission = null;
submitCPRData(apiKey, checkpointPassRates);
}
});
function processCPRs(data, checkpointPassRates) {
const scenarioName = String(data.scenario.name);
if (!checkpointPassRates[scenarioName]) {
checkpointPassRates[scenarioName] = {};
}
// Only capture from EMPTY slots (slot.player === null)
// Filled slots show the other player's CPR, not yours
data.playerSlots.forEach(slot => {
const slotName = String(slot.name);
if (slot.player === null && slot.successChance > 0) {
checkpointPassRates[scenarioName][slotName] = slot.successChance;
}
});
return checkpointPassRates;
}
function showPopup(message, type = 'info') {
const popup = document.createElement('div');
popup.innerHTML = message.replace(/\n/g, '<br>');
popup.style.position = 'fixed';
popup.style.top = '80px';
popup.style.right = '20px';
popup.style.zIndex = 9999;
popup.style.padding = '10px 15px';
popup.style.borderRadius = '5px';
popup.style.fontFamily = 'Verdana, sans-serif';
popup.style.fontSize = '13px';
popup.style.fontWeight = 'bold';
popup.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.6)';
popup.style.opacity = '1';
popup.style.transition = 'opacity 1s ease';
popup.style.maxWidth = '350px';
switch (type) {
case 'error':
popup.style.backgroundColor = '#2b1b1b';
popup.style.color = '#f14c4c';
break;
case 'success':
popup.style.backgroundColor = '#1d2b1d';
popup.style.color = '#70db70';
break;
case 'info':
default:
popup.style.backgroundColor = '#182432';
popup.style.color = '#5ec8f2';
break;
}
document.body.appendChild(popup);
setTimeout(() => {
popup.style.opacity = '0';
setTimeout(() => popup.remove(), 1000);
}, 5000);
}
// Fetch Interception
const win = isTornPDA ? window : (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);
const originalFetch = win.fetch;
win.fetch = async function(resource, config) {
const url = typeof resource === 'string' ? resource : resource.url;
if (config?.method?.toUpperCase() !== 'POST' || !url.includes(TARGET_URL_BASE)) {
return originalFetch.apply(this, arguments);
}
let isRecruitingGroup = false;
if (config?.body instanceof FormData) {
isRecruitingGroup = config.body.get('group') === 'Recruiting';
} else if (config?.body) {
isRecruitingGroup = config.body.toString().includes('group=Recruiting');
}
if (!isRecruitingGroup) {
return originalFetch.apply(this, arguments);
}
const response = await originalFetch.apply(this, arguments);
try {
const json = JSON.parse(await response.clone().text());
if (json.success && json.data && json.data.length > 1) {
// Build fresh data from current game state - no caching
let checkpointPassRates = {};
json.data.forEach(crimeData => {
checkpointPassRates = processCPRs(crimeData, checkpointPassRates);
});
const storedApiKey = getValue(`${STORAGE_PREFIX}api_key`, null);
if (storedApiKey) {
submitCPRData(storedApiKey, checkpointPassRates);
}
}
} catch (err) {
console.error('Error processing response:', err);
}
return response;
};
})();