Torn Stats Faction CPR Tracker

Tracks CPR data for organized crimes by intercepting Fetch requests, compatible with TornPDA and PC

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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;
    };
})();