Floating, draggable tracker for Graffiti with delta tracking and Nerve planner. Mobile optimized.
// ==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);
})();