Torn Stats Faction CPR Tracker

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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