Torn PDA - Graffiti Rep Tracker

Floating, draggable tracker for Graffiti with delta tracking and Nerve planner. Mobile optimized.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn PDA - Graffiti Rep Tracker
// @namespace    Torn.Crimes2.Graffiti.Tracker
// @version      3.4
// @description  Floating, draggable tracker for Graffiti with delta tracking and Nerve planner. Mobile optimized.
// @author       [Your Torn Name Here]
// @license      MIT
// @match        https://www.torn.com/loader.php?sid=crimes*
// @match        https://www.torn.com/page.php?sid=crimes*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(() => {
    "use strict";

    if (document.getElementById("gt-gui")) return;

    const STORAGE_KEY = "torn_graffiti_rep_data";
    const MULTIPLIER_KEY = "torn_graffiti_multiplier";
    let isGuiClosed = false;
    let isMinimized = false;

    // Load saved multiplier or default to 1.0
    let savedMultiplier = localStorage.getItem(MULTIPLIER_KEY);
    if (!savedMultiplier || isNaN(parseFloat(savedMultiplier))) {
        savedMultiplier = "1.0";
    }

    // Inject CSS
    const style = document.createElement("style");
    style.textContent = `
        #gt-gui {
            position: fixed; bottom: 70px; right: 10px; width: 240px;
            border: 1px solid #333; border-radius: 8px; color: #ddd;
            font-family: Arial, sans-serif; font-size: 11px; z-index: 999999;
            box-shadow: 0px 4px 15px rgba(0,0,0,0.8); display: none;
            will-change: transform; background: rgba(15, 15, 15, 0.95);
        }
        
        /* Header */
        #gt-header {
            background: #1a1a1a; padding: 8px 10px; border-bottom: 1px solid #333;
            border-radius: 8px 8px 0 0; display: flex; justify-content: space-between;
            align-items: center; cursor: grab; touch-action: none;
        }
        #gt-header:active { cursor: grabbing; }
        .gt-title { font-weight: bold; color: #e0ac16; font-size: 12px; pointer-events: none; }
        .gt-controls { display: flex; gap: 4px; }
        .gt-controls button {
            background: #333; color: #fff; border: none; width: 22px; height: 22px; 
            border-radius: 4px; cursor: pointer; font-weight: bold; line-height: 1;
        }
        .gt-controls button:active { background: #555; }

        /* Body */
        #gt-body { padding: 10px; border-radius: 0 0 8px 8px; }
        .gt-settings-row {
            display: flex; justify-content: center; align-items: center; gap: 5px;
            background: #1a1a1a; padding: 4px; border-radius: 4px; border: 1px solid #2a2a2a;
            margin-bottom: 8px;
        }
        #gt-multiplier {
            width: 40px; background: #000; color: #44ff44; border: 1px solid #444;
            border-radius: 3px; text-align: center; font-size: 11px; padding: 2px;
        }
        .gt-timestamp { font-size: 10px; color: #aaa; text-align: center; margin-bottom: 8px; }
        .gt-status { font-size: 11px; text-align: center; margin-bottom: 8px; font-weight: bold; height: 12px; }
        
        /* Grid Layout */
        .gt-grid-header {
            display: flex; justify-content: space-between; padding-bottom: 4px;
            border-bottom: 1px solid #444; font-weight: bold; color: #fff; margin-bottom: 4px;
        }
        .gt-col-1 { flex: 2; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
        .gt-col-2 { flex: 1.5; text-align: right; display: flex; justify-content: flex-end; align-items: center; }
        .gt-col-3 { flex: 1; text-align: right; }

        .gt-data-container {
            max-height: 150px; overflow-y: auto; margin-bottom: 10px;
            scrollbar-width: thin; scrollbar-color: #555 #222;
        }
        .gt-area-row { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #2a2a2a; }
        
        /* Typography */
        .gt-locked { color: #ff4444; font-style: italic; }
        .gt-rep { color: #44ff44; font-weight: bold; }
        .gt-delta-pos { color: #44ff44; font-size: 9px; margin-left: 4px; font-weight: normal; }
        .gt-delta-neg { color: #ff4444; font-size: 9px; margin-left: 4px; font-weight: normal; }
        .gt-delta-zero { color: #ffff44; font-size: 9px; margin-left: 4px; font-weight: normal; }
        .gt-nerve-est { color: #ff9900; }
        .gt-nerve-done { color: #aaa; }

        .gt-btn-fetch {
            background: #1e4d1e; color: #fff; border: 1px solid #2e7d2e; padding: 8px;
            border-radius: 4px; cursor: pointer; width: 100%; font-weight: bold; font-size: 11px;
        }
        .gt-btn-fetch:active { background: #2e7d2e; }
    `;
    document.head.appendChild(style);

    // Create GUI
    const gui = document.createElement("div");
    gui.id = "gt-gui";
    gui.innerHTML = `
        <div id="gt-header">
            <span class="gt-title">Graffiti Rep</span>
            <div class="gt-controls">
                <button id="gt-min-btn">-</button>
                <button id="gt-close-btn">X</button>
            </div>
        </div>
        <div id="gt-body">
            <div class="gt-settings-row">
                <span style="color: #aaa;">Avg Rep/Att:</span>
                <input type="number" id="gt-multiplier" value="${savedMultiplier}" step="0.1" min="0.1">
            </div>
            <div class="gt-timestamp" id="gt-time">No data fetched yet</div>
            <div class="gt-status" id="gt-status"></div>
            
            <div class="gt-grid-header">
                <span class="gt-col-1">Area</span>
                <span class="gt-col-2">Rep (Δ)</span>
                <span class="gt-col-3">Nerve</span>
            </div>

            <div class="gt-data-container" id="gt-list"></div>
            <button class="gt-btn-fetch" id="gt-fetch-btn">FETCH DATA</button>
        </div>
    `;
    document.body.appendChild(gui);

    const timeDisplay = document.getElementById("gt-time");
    const listDisplay = document.getElementById("gt-list");
    const statusDisplay = document.getElementById("gt-status");
    const multInput = document.getElementById("gt-multiplier");
    const header = document.getElementById("gt-header");
    const body = document.getElementById("gt-body");

    // Live update when multiplier changes
    multInput.addEventListener("input", () => {
        let val = parseFloat(multInput.value);
        if (!isNaN(val) && val > 0) {
            localStorage.setItem(MULTIPLIER_KEY, val.toString());
            loadSavedData(); // Re-render the list immediately
        }
    });

    function flashStatus(msg, isError) {
        statusDisplay.innerText = msg;
        statusDisplay.style.color = isError ? "#ff4444" : "#44ff44";
        setTimeout(() => { statusDisplay.innerText = ""; }, 3000);
    }

    function calculateNerve(repNum, isLocked, mult) {
        if (isLocked) return `<span class="gt-nerve-est">~${Math.ceil((500 / mult) * 3)}</span>`;
        if (repNum >= 500) return `<span class="gt-nerve-done">Done</span>`;
        return `<span class="gt-nerve-est">~${Math.ceil(((500 - repNum) / mult) * 3)}</span>`;
    }

    function loadSavedData() {
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved) {
            try {
                const data = JSON.parse(saved);
                timeDisplay.innerText = `Last Fetch: ${data.timestamp}`;
                
                let mult = parseFloat(multInput.value) || 1.0;
                if (mult <= 0) mult = 0.1; // Safety fallback
                
                listDisplay.innerHTML = "";
                data.areas.forEach(area => {
                    const row = document.createElement("div");
                    row.className = "gt-area-row";
                    
                    const isLocked = area.rep.includes("Locked");
                    
                    // Column 1: Area
                    const col1 = document.createElement("span");
                    col1.className = "gt-col-1";
                    col1.innerText = area.name;
                    
                    // Column 2: Rep & Delta
                    const col2 = document.createElement("span");
                    col2.className = "gt-col-2";
                    
                    const repSpan = document.createElement("span");
                    repSpan.className = isLocked ? "gt-locked" : "gt-rep";
                    repSpan.innerText = area.rep;
                    col2.appendChild(repSpan);

                    if (area.hasOldData) {
                        const deltaSpan = document.createElement("span");
                        if (area.delta > 0) {
                            deltaSpan.className = "gt-delta-pos";
                            deltaSpan.innerText = `(+${area.delta})`;
                        } else if (area.delta < 0) {
                            deltaSpan.className = "gt-delta-neg";
                            deltaSpan.innerText = `(${area.delta})`;
                        } else {
                            deltaSpan.className = "gt-delta-zero";
                            deltaSpan.innerText = `(0)`;
                        }
                        col2.appendChild(deltaSpan);
                    }

                    // Column 3: Nerve
                    const col3 = document.createElement("span");
                    col3.className = "gt-col-3";
                    col3.innerHTML = calculateNerve(area.rawRep, isLocked, mult);
                    
                    row.appendChild(col1);
                    row.appendChild(col2);
                    row.appendChild(col3);
                    listDisplay.appendChild(row);
                });
            } catch (e) {
                console.error("Graffiti Tracker Error parsing saved data.");
            }
        }
    }

    function fetchReputation() {
        const cards = document.querySelectorAll('[class*="crimeWrapper"], [class*="crimeOption"], [class*="crimePanel"]');
        
        if (cards.length === 0) {
            flashStatus("Wait for areas to load...", true);
            return;
        }

        const oldSaved = localStorage.getItem(STORAGE_KEY);
        const oldData = oldSaved ? JSON.parse(oldSaved) : null;
        const oldRepMap = {};
        
        if (oldData && oldData.areas) {
            oldData.areas.forEach(a => {
                let oldRepNum = a.rawRep !== undefined ? a.rawRep : parseInt(a.rep, 10);
                if (isNaN(oldRepNum)) oldRepNum = 0;
                oldRepMap[a.name] = oldRepNum;
            });
        }

        const areas = [];
        const seenNames = new Set();

        cards.forEach(card => {
            const lines = card.innerText.split('\n').map(line => line.trim()).filter(line => line !== "");
            
            if (lines.length >= 2) {
                const areaName = lines[0];
                const repValueStr = lines[1];
                const startsWithNumber = /^\d/.test(areaName);
                
                if (!startsWithNumber && !seenNames.has(areaName)) {
                    seenNames.add(areaName);
                    
                    let newRepNum = parseInt(repValueStr, 10);
                    if (isNaN(newRepNum)) newRepNum = 0;
                    
                    // Clean format: just the number, strip out the "(3)" and percentages
                    let finalStatus = newRepNum.toString();
                    if (repValueStr === "0" || repValueStr.includes("Locked") || isNaN(parseInt(repValueStr, 10))) {
                        finalStatus = "Locked";
                        newRepNum = 0;
                    }

                    let delta = 0;
                    let hasOldData = oldRepMap.hasOwnProperty(areaName);
                    if (hasOldData) { delta = newRepNum - oldRepMap[areaName]; }

                    areas.push({ name: areaName, rep: finalStatus, rawRep: newRepNum, delta: delta, hasOldData: hasOldData });
                }
            }
        });

        if (areas.length > 0) {
            const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true };
            const timestampLocal = new Date().toLocaleString(undefined, options);

            localStorage.setItem(STORAGE_KEY, JSON.stringify({ timestamp: timestampLocal, areas: areas }));
            loadSavedData();
            flashStatus("Success!", false);
        } else {
            flashStatus("Failed to read text.", true);
        }
    }

    // Window Controls
    document.getElementById("gt-fetch-btn").addEventListener("click", fetchReputation);
    document.getElementById("gt-close-btn").addEventListener("click", () => {
        isGuiClosed = true;
        gui.style.display = "none";
    });
    document.getElementById("gt-min-btn").addEventListener("click", () => {
        isMinimized = !isMinimized;
        body.style.display = isMinimized ? "none" : "block";
        document.getElementById("gt-min-btn").innerText = isMinimized ? "+" : "-";
        if (isMinimized) header.style.borderRadius = "8px"; 
        else header.style.borderRadius = "8px 8px 0 0";
    });

    // Drag and Drop
    let isDragging = false, currentX, currentY, initialX, initialY, xOffset = 0, yOffset = 0;

    function dragStart(e) {
        if (e.target.tagName === "BUTTON") return; 
        if (e.type === "touchstart") {
            initialX = e.touches[0].clientX - xOffset;
            initialY = e.touches[0].clientY - yOffset;
        } else {
            initialX = e.clientX - xOffset;
            initialY = e.clientY - yOffset;
        }
        isDragging = true;
    }
    function dragEnd() {
        initialX = currentX; initialY = currentY; isDragging = false;
    }
    function drag(e) {
        if (!isDragging) return;
        e.preventDefault(); 
        if (e.type === "touchmove") {
            currentX = e.touches[0].clientX - initialX;
            currentY = e.touches[0].clientY - initialY;
        } else {
            currentX = e.clientX - initialX;
            currentY = e.clientY - initialY;
        }
        xOffset = currentX; yOffset = currentY;
        gui.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;
    }

    header.addEventListener("touchstart", dragStart, { passive: false });
    document.addEventListener("touchend", dragEnd);
    document.addEventListener("touchmove", drag, { passive: false });
    header.addEventListener("mousedown", dragStart);
    document.addEventListener("mouseup", dragEnd);
    document.addEventListener("mousemove", drag);

    // SPA Router
    setInterval(() => {
        const url = window.location.href;
        const isGraffitiPage = url.includes("crimes") && url.includes("graffiti");
        
        if (isGraffitiPage && !isGuiClosed) {
            if (gui.style.display !== "block") {
                loadSavedData();
                gui.style.display = "block";
            }
        } else {
            gui.style.display = "none";
            if (!isGraffitiPage) {
                isGuiClosed = false; 
            }
        }
    }, 1000);

})();