// ==UserScript==
// @name hoggson's Chain Watcher
// @version 2.3
// @description Alerts player when chain timer drops below user-defined value by flashing the screen red and/or playing a sound. Also provides a Random Target button to quickly attack a random level 1 player, all accessible via a toggleable popup menu with persistent settings. Clicking the icon in the Chain Watch button loads a random target directly. Icon changes on hover to indicate target mode. Now includes Recent Attacks viewer (sorted by respect) with API key input and toggle.
// @author hoggson
// @match https://www.torn.com/*
// @icon https://torn.com/favicon.ico
// @grant none
// @license MIT
// @namespace https://modgaming.co.uk/hcw/hcw.user.js
// ==/UserScript==
(function() {
'use strict';
let previousStateBelowThreshold = false;
let alertedForCurrentThreshold = false;
// Load saved settings or defaults
let alertThresholdInSeconds = parseInt(localStorage.getItem('alertThreshold')) || 150;
let selectedSound = localStorage.getItem('alertSound') || 'alarm';
let alertVolume = (localStorage.getItem('alertVolume') || 100) / 100;
let openMode = localStorage.getItem('openMode') || 'current';
let screenFlashEnabled = (localStorage.getItem('screenFlashEnabled') !== 'false');
let apiKey = localStorage.getItem('tornApiKey') || '';
// Sounds
const sounds = {
silent: null,
beep: 'https://hoggson.co.uk/hcw/beep.mp3',
alarm: 'https://hoggson.co.uk/hcw/alarm.mp3',
siren: 'https://hoggson.co.uk/hcw/siren.mp3'
};
// Random Target Finder
const minID = 3000000;
const maxID = 3400000;
function getRandomNumber(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// --- ICON TOGGLE BUTTON ---
const toggleButton = document.createElement('button');
toggleButton.textContent = 'Chain Watch';
toggleButton.style.position = 'fixed';
toggleButton.style.top = '5%';
toggleButton.style.right = '10px';
toggleButton.style.zIndex = '10001';
toggleButton.style.backgroundColor = '#28a745';
toggleButton.style.color = 'white';
toggleButton.style.border = 'none';
toggleButton.style.padding = '3px 8px';
toggleButton.style.borderRadius = '4px';
toggleButton.style.cursor = 'pointer';
toggleButton.style.display = 'flex';
toggleButton.style.alignItems = 'center';
const icon = document.createElement('img');
icon.src = 'https://hoggson.co.uk/hcw/chainwatch.ico';
icon.style.width = '16px';
icon.style.height = '16px';
icon.style.marginRight = '5px';
toggleButton.prepend(icon);
document.body.appendChild(toggleButton);
// Hover swap
icon.addEventListener('mouseenter', () => {
icon.src = 'https://hoggson.co.uk/hcw/chainwatchtarget.ico';
});
icon.addEventListener('mouseleave', () => {
icon.src = 'https://hoggson.co.uk/hcw/chainwatch.ico';
});
// Icon click → Random Target
icon.addEventListener('click', (e) => {
e.stopPropagation();
let randID = getRandomNumber(minID, maxID);
let profileLink = `https://www.torn.com/loader.php?sid=attack&user2ID=${randID}`;
if (openMode === 'newtab') {
window.open(profileLink, '_blank');
} else {
window.location.href = profileLink;
}
});
// --- POPUP MENU ---
const popup = document.createElement('div');
popup.style.position = 'fixed';
popup.style.top = `calc(${toggleButton.style.top} + 35px)`;
popup.style.right = '10px';
popup.style.zIndex = '10000';
popup.style.background = 'rgba(0,0,0,0.8)';
popup.style.padding = '8px';
popup.style.borderRadius = '6px';
popup.style.display = 'none';
document.body.appendChild(popup);
function obfuscateKey(key) {
if (!key) return '';
return key.slice(0, 4) + '*'.repeat(Math.max(0, key.length - 4));
}
function createControls() {
// Timer dropdown
const timerDropdown = document.createElement('select');
[30, 60, 90, 120, 150, 180, 210, 240, 270].forEach(seconds => {
const option = document.createElement('option');
option.value = seconds;
option.textContent = `${seconds / 60} minutes`;
timerDropdown.appendChild(option);
});
timerDropdown.value = alertThresholdInSeconds;
timerDropdown.addEventListener('change', (e) => {
alertThresholdInSeconds = parseInt(e.target.value);
localStorage.setItem('alertThreshold', alertThresholdInSeconds);
alertedForCurrentThreshold = false;
});
// Screen Flash
const flashWrapper = document.createElement('label');
flashWrapper.style.color = 'white';
flashWrapper.style.marginLeft = '5px';
flashWrapper.style.fontSize = '12px';
flashWrapper.style.display = 'inline-flex';
flashWrapper.style.alignItems = 'center';
const flashCheckbox = document.createElement('input');
flashCheckbox.type = 'checkbox';
flashCheckbox.checked = screenFlashEnabled;
flashCheckbox.style.marginRight = '3px';
flashCheckbox.addEventListener('change', (e) => {
screenFlashEnabled = e.target.checked;
localStorage.setItem('screenFlashEnabled', screenFlashEnabled);
});
flashWrapper.appendChild(flashCheckbox);
flashWrapper.appendChild(document.createTextNode('Screen Flash'));
// Sound dropdown
const soundDropdown = document.createElement('select');
Object.keys(sounds).forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = key.charAt(0).toUpperCase() + key.slice(1);
soundDropdown.appendChild(option);
});
soundDropdown.value = selectedSound;
soundDropdown.addEventListener('change', (e) => {
selectedSound = e.target.value;
localStorage.setItem('alertSound', selectedSound);
});
// Volume slider
const volumeWrapper = document.createElement('div');
volumeWrapper.style.display = 'inline-flex';
volumeWrapper.style.alignItems = 'center';
volumeWrapper.style.marginLeft = '5px';
const volumeSlider = document.createElement('input');
volumeSlider.type = 'range';
volumeSlider.min = 0;
volumeSlider.max = 100;
volumeSlider.value = localStorage.getItem('alertVolume') || 100;
const volumeLabel = document.createElement('span');
volumeLabel.textContent = `Volume: ${volumeSlider.value}%`;
volumeLabel.style.color = 'white';
volumeLabel.style.marginLeft = '5px';
volumeLabel.style.fontSize = '12px';
volumeSlider.addEventListener('input', (e) => {
alertVolume = e.target.value / 100;
localStorage.setItem('alertVolume', e.target.value);
volumeLabel.textContent = `Volume: ${e.target.value}%`;
});
volumeWrapper.appendChild(volumeSlider);
volumeWrapper.appendChild(volumeLabel);
// Test button
const testButton = document.createElement('button');
testButton.textContent = 'Test Sound';
testButton.style.marginLeft = '5px';
testButton.style.backgroundColor = '#28a745';
testButton.style.color = 'white';
testButton.style.border = 'none';
testButton.style.padding = '3px 8px';
testButton.style.borderRadius = '4px';
testButton.style.cursor = 'pointer';
testButton.addEventListener('click', () => {
playAlertSound();
});
// Open mode dropdown
const openModeDropdown = document.createElement('select');
['current', 'newtab'].forEach(mode => {
const option = document.createElement('option');
option.value = mode;
option.textContent = mode === 'current' ? 'Current Window' : 'New Tab';
openModeDropdown.appendChild(option);
});
openModeDropdown.value = openMode;
openModeDropdown.style.marginLeft = '5px';
openModeDropdown.addEventListener('change', (e) => {
openMode = e.target.value;
localStorage.setItem('openMode', openMode);
});
// Random Target button
const randomTargetButton = document.createElement('button');
randomTargetButton.textContent = 'Random Target';
randomTargetButton.style.marginLeft = '5px';
randomTargetButton.style.backgroundColor = '#28a745';
randomTargetButton.style.color = 'white';
randomTargetButton.style.border = 'none';
randomTargetButton.style.padding = '3px 8px';
randomTargetButton.style.borderRadius = '4px';
randomTargetButton.style.cursor = 'pointer';
randomTargetButton.addEventListener('click', () => {
let randID = getRandomNumber(minID, maxID);
let profileLink = `https://www.torn.com/loader.php?sid=attack&user2ID=${randID}`;
if (openMode === 'newtab') {
window.open(profileLink, '_blank');
} else {
window.location.href = profileLink;
}
});
// Help button
const helpButton = document.createElement('button');
helpButton.textContent = 'Help';
helpButton.style.marginLeft = '5px';
helpButton.style.backgroundColor = '#007bff';
helpButton.style.color = 'white';
helpButton.style.border = 'none';
helpButton.style.padding = '3px 8px';
helpButton.style.borderRadius = '4px';
helpButton.style.cursor = 'pointer';
helpButton.addEventListener('click', () => {
window.open('https://hoggson.co.uk/hcw', '_blank');
});
// Attack List toggle checkbox
const attackToggleWrapper = document.createElement('label');
attackToggleWrapper.style.color = 'white';
attackToggleWrapper.style.marginTop = '5px';
attackToggleWrapper.style.fontSize = '12px';
attackToggleWrapper.style.display = 'inline-flex';
attackToggleWrapper.style.alignItems = 'center';
const attackToggleCheckbox = document.createElement('input');
attackToggleCheckbox.type = 'checkbox';
attackToggleCheckbox.style.marginRight = '3px';
attackToggleCheckbox.checked = (localStorage.getItem('attackListEnabled') === 'true');
attackToggleCheckbox.addEventListener('change', (e) => {
const enabled = e.target.checked;
localStorage.setItem('attackListEnabled', enabled);
if (enabled) {
showAttackBox();
} else {
hideAttackBox();
}
});
attackToggleWrapper.appendChild(attackToggleCheckbox);
attackToggleWrapper.appendChild(document.createTextNode('Enable Attack List'));
// API Key input
const apiWrapper = document.createElement('div');
apiWrapper.style.marginTop = '8px';
apiWrapper.style.display = 'flex';
apiWrapper.style.flexDirection = 'column';
const apiLabel = document.createElement('label');
apiLabel.textContent = 'Torn API Key';
apiLabel.style.color = 'white';
apiLabel.style.fontSize = '12px';
apiLabel.style.marginBottom = '3px';
const apiInput = document.createElement('input');
apiInput.type = 'text';
apiInput.style.width = '100%';
apiInput.style.padding = '3px';
apiInput.style.borderRadius = '4px';
apiInput.style.border = '1px solid #555';
apiInput.value = obfuscateKey(apiKey);
apiInput.addEventListener('focus', () => {
apiInput.value = apiKey; // show full key when editing
});
apiInput.addEventListener('blur', () => {
apiKey = apiInput.value.trim();
localStorage.setItem('tornApiKey', apiKey);
apiInput.value = obfuscateKey(apiKey); // mask again
if (apiKey && apiKey.length > 4) {
// Auto-enable attack list
localStorage.setItem('attackListEnabled', 'true');
showAttackBox();
attackToggleCheckbox.checked = true;
} else {
// ✅ If key is cleared, disable attack list
localStorage.setItem('attackListEnabled', 'false');
hideAttackBox();
attackToggleCheckbox.checked = false;
}
});
apiWrapper.appendChild(apiLabel);
apiWrapper.appendChild(apiInput);
// Append everything to popup
popup.appendChild(timerDropdown);
popup.appendChild(flashWrapper);
popup.appendChild(soundDropdown);
popup.appendChild(volumeWrapper);
popup.appendChild(testButton);
popup.appendChild(openModeDropdown);
popup.appendChild(randomTargetButton);
popup.appendChild(attackToggleWrapper);
popup.appendChild(helpButton);
popup.appendChild(apiWrapper);
}
// Toggle popup visibility
toggleButton.addEventListener('click', () => {
popup.style.display = (popup.style.display === 'none') ? 'block' : 'none';
});
// --- CORE FUNCTIONS (Chain Watcher) ---
function checkChainTimer() {
const timerElement = document.querySelector('.bar-timeleft___B9RGV');
if (timerElement) {
const timerText = timerElement.textContent.trim();
const timeParts = timerText.split(':');
const minutes = parseInt(timeParts[0], 10);
const seconds = parseInt(timeParts[1], 10);
const totalTimeInSeconds = (minutes * 60) + seconds;
if (totalTimeInSeconds < alertThresholdInSeconds) {
if (!alertedForCurrentThreshold) {
alertedForCurrentThreshold = true;
}
if (screenFlashEnabled) {
flashScreenRed();
}
playAlertSound();
previousStateBelowThreshold = true;
} else {
previousStateBelowThreshold = false;
alertedForCurrentThreshold = false;
}
}
}
function flashScreenRed() {
const flashDiv = document.createElement('div');
flashDiv.style.position = 'fixed';
flashDiv.style.top = '0';
flashDiv.style.left = '0';
flashDiv.style.width = '100vw';
flashDiv.style.height = '100vh';
flashDiv.style.backgroundColor = 'red';
flashDiv.style.zIndex = '-1';
document.body.appendChild(flashDiv);
setTimeout(() => { flashDiv.remove(); }, 1000);
}
function playAlertSound() {
if (selectedSound === 'silent') return;
const audio = new Audio(sounds[selectedSound]);
audio.volume = alertVolume;
audio.play().catch(err => {
console.warn("Audio playback failed:", err);
});
}
// Build controls inside popup
createControls();
// Start checking the chain timer every 2 seconds
setInterval(checkChainTimer, 2000);
// --- ATTACK VIEWER FUNCTIONS ---
let currentPage = 0;
let cachedAttacks = [];
const hospitalTimers = {};
const profileCache = {};
let hospitalInterval = null;
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs}s`;
}
function startHospitalCountdown() {
if (hospitalInterval) clearInterval(hospitalInterval);
hospitalInterval = setInterval(() => {
for (const id in hospitalTimers) {
const timer = hospitalTimers[id];
if (timer.remaining > 0) {
timer.remaining -= 5;
const el = document.getElementById(`hospital-${id}`);
if (el) el.textContent = `🏥 Hospital: ${formatTime(timer.remaining)}`;
}
}
}, 5000);
}
async function fetchAttacks() {
if (!apiKey) return [];
try {
const response = await fetch(`https://api.torn.com/user/?selections=attacks&key=${apiKey}`);
const data = await response.json();
const attacks = Object.values(data.attacks || {});
return attacks
.sort((a, b) => parseFloat(b.respect || 0) - parseFloat(a.respect || 0))
.slice(0, 100);
} catch {
return [];
}
}
async function fetchDefenderProfile(id) {
if (profileCache[id]) return profileCache[id];
try {
const res = await fetch(`https://api.torn.com/user/${id}?selections=profile&key=${apiKey}`);
const data = await res.json();
const level = data.level || 'N/A';
let hospitalTime = null;
let isHospital = false;
let until = 0;
if (data.status?.state === 'Hospital') {
isHospital = true;
until = data.status.until;
const now = Math.floor(Date.now() / 1000);
const remaining = until - now;
if (remaining > 0) hospitalTime = formatTime(remaining);
}
profileCache[id] = { level, hospitalTime, isHospital, until };
return profileCache[id];
} catch {
return { level: 'N/A', hospitalTime: null, isHospital: false, until: 0 };
}
}
async function renderPage() {
const box = document.getElementById('attackBox');
box.innerHTML = '<h2>Recent Attacks (Sorted by Respect)</h2>';
Object.keys(hospitalTimers).forEach(id => delete hospitalTimers[id]);
const seenIds = new Set();
const pageAttacks = [];
for (let i = currentPage * 10; i < cachedAttacks.length && pageAttacks.length < 10; i++) {
const attack = cachedAttacks[i];
const id = attack.defender_id;
if (!seenIds.has(id)) {
seenIds.add(id);
pageAttacks.push(attack);
}
}
for (const attack of pageAttacks) {
const name = attack.defender_name || 'Unknown';
const id = attack.defender_id || '';
const respect = parseFloat(attack.respect || 0);
const { level, hospitalTime, isHospital, until } = await fetchDefenderProfile(id);
const entry = document.createElement('div');
entry.className = `attack-entry ${isHospital ? 'hospital' : 'alive'}`;
entry.innerHTML = `
<a href="https://www.torn.com/profiles.php?XID=${id}" target="_blank"><strong>${name}</strong></a>
(Lvl ${level})<br>
Respect: ${respect.toFixed(2)}
`;
if (isHospital) {
const now = Math.floor(Date.now() / 1000);
const remaining = until - now;
hospitalTimers[id] = { remaining };
entry.innerHTML += `<br><span id="hospital-${id}">🏥 Hospital: ${formatTime(remaining)}</span>`;
} else {
entry.innerHTML += `<br>🟢 Alive`;
}
box.appendChild(entry);
}
// --- Controls row ---
const controls = document.createElement('div');
controls.className = 'refresh-controls';
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.justifyContent = 'space-between';
// Refresh button
const refreshDataBtn = document.createElement('button');
refreshDataBtn.textContent = '🔄 Refresh';
refreshDataBtn.onclick = async () => {
cachedAttacks = await fetchAttacks();
Object.keys(profileCache).forEach(id => delete profileCache[id]);
renderPage();
};
// Previous page button
const prevPageBtn = document.createElement('button');
prevPageBtn.textContent = '⏮️ Prev';
prevPageBtn.onclick = () => {
currentPage--;
if (currentPage < 0) {
currentPage = Math.floor((cachedAttacks.length - 1) / 10);
}
renderPage();
};
// Page indicator (short form, centered)
const pageIndicator = document.createElement('span');
pageIndicator.style.color = 'white';
pageIndicator.style.flex = '1';
pageIndicator.style.textAlign = 'center';
pageIndicator.textContent = `${currentPage + 1} of ${Math.max(1, Math.ceil(cachedAttacks.length / 10))}`;
// Next page button
const nextPageBtn = document.createElement('button');
nextPageBtn.textContent = '⏭️ Next';
nextPageBtn.onclick = () => {
currentPage++;
if (currentPage * 10 >= cachedAttacks.length) currentPage = 0;
renderPage();
};
// Build row
controls.appendChild(refreshDataBtn);
controls.appendChild(prevPageBtn);
controls.appendChild(pageIndicator);
controls.appendChild(nextPageBtn);
box.appendChild(controls);
startHospitalCountdown();
}
function showAttackBox() {
let box = document.getElementById('attackBox');
if (!box) {
box = document.createElement('div');
box.id = 'attackBox';
box.style.position = 'fixed';
box.style.top = 'calc(5% + 40px)';
box.style.right = '10px';
box.style.width = '420px';
box.style.maxHeight = '600px';
box.style.overflowY = 'auto';
box.style.background = 'rgba(0,0,0,0.8)';
box.style.color = '#fff';
box.style.border = '1px solid #444';
box.style.borderRadius = '6px';
box.style.padding = '8px';
box.style.zIndex = '9999';
box.style.fontFamily = 'Arial, sans-serif';
document.body.appendChild(box);
}
box.style.display = 'block';
(async () => {
cachedAttacks = await fetchAttacks();
renderPage();
})();
}
function hideAttackBox() {
const box = document.getElementById('attackBox');
if (box) box.style.display = 'none';
}
// Restore Attack List state on load
if (localStorage.getItem('attackListEnabled') === 'true' && apiKey) {
showAttackBox();
}
// --- ATTACK VIEWER STYLES (Unified with Chain Watcher) ---
const style = document.createElement('style');
style.textContent = `
#attackBox {
background: rgba(0,0,0,0.8);
color: white;
border: 1px solid #444;
border-radius: 6px;
padding: 8px;
font-size: 12px;
}
#attackBox h2 {
margin-top: 0;
font-size: 14px;
border-bottom: 1px solid #555;
padding-bottom: 4px;
color: #28a745; /* same green as Chain Watcher button */
}
.attack-entry {
border-bottom: 1px solid #333;
padding: 5px 0;
}
.attack-entry a {
color: #4fc3f7;
text-decoration: none;
}
.attack-entry a:hover {
text-decoration: underline;
}
.alive::before {
content: "🟢 ";
}
.hospital::before {
content: "🏥 ";
color: #dc3545; /* Bootstrap red */
}
.refresh-controls {
margin-top: 10px;
display: flex;
gap: 10px;
}
.refresh-controls button {
background-color: #28a745;
color: white;
border: none;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.refresh-controls button:hover {
background-color: #218838;
}
`;
document.head.appendChild(style);
})();