// ==UserScript==
// @name Deadman Watcher
// @namespace http://tampermonkey.net/
// @version 1.1
// @description Monitors a user's profile and provides desktop and Discord notifications when they are out of the hospital. Features a watchlist and per-user notification settings.
// @author HeyItzWerty [3626448]
// @match https://www.torn.com/profiles.php?XID=*
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @connect discord.com
// @license MIT
// @supportURL https://www.torn.com/messages.php#/p=compose&XID=3626448
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function() {
'use strict';
// --- CONFIGURATION & GLOBALS ---
const SCRIPT_PREFIX = 'dmw_';
const TORN_GREEN = '#85ea2d';
const TORN_GREY = '#d4d4d4';
const ICONS = {
gravestone: `<svg xmlns="http://www.w3.org/2000/svg" class="default___XXAGt profileButtonIcon" width="46" height="46" viewBox="0 0 46 46"><path d="M23,5 C17.48,5 13,9.48 13,15 L13,25 C13,26.1 13.9,27 15,27 L15,37 C15,38.1 15.9,39 17,39 L29,39 C30.1,39 31,38.1 31,37 L31,27 C32.1,27 33,26.1 33,25 L33,15 C33,9.48 28.52,5 23,5 Z M21,14 L25,14 L25,22 L27,22 L27,24 L19,24 L19,22 L21,22 L21,14 Z" /></svg>`,
settings: `<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 0 24 24" width="20" fill="currentColor"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61-.25-1.17-.59-1.69-.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></svg>`
};
let originalTitle = document.title;
let originalActionText = "What would you like to do?";
let flashInterval = null;
let statusObserver = null;
let titleUpdateInterval = null;
let hospitalStartTime = 0;
let hospitalInitialSeconds = 0;
// --- SETTINGS MANAGEMENT ---
const DEFAULT_MESSAGES = [
"**%USERNAME%** has risen from the dead! Attack now: https://www.torn.com/loader2.php?sid=getInAttack&user2ID=%USERID%",
"Looks like **%USERNAME%** is back on their feet. Go put them back down: https://www.torn.com/loader2.php?sid=getInAttack&user2ID=%USERID%",
"The doctors did their job on **%USERNAME%**. Now you do yours: https://www.torn.com/loader2.php?sid=getInAttack&user2ID=%USERID%"
];
const DEFAULT_SETTINGS = { autoAttack: false, webhookUrl: '', webhookMessage: '' };
async function loadSettings() {
const saved = await GM_getValue(SCRIPT_PREFIX + 'settings', {});
return { ...DEFAULT_SETTINGS, ...saved };
}
async function saveSettings() {
const newSettings = {
autoAttack: document.getElementById(`${SCRIPT_PREFIX}auto-attack`).checked,
webhookUrl: document.getElementById(`${SCRIPT_PREFIX}webhook-url`).value.trim(),
webhookMessage: document.getElementById(`${SCRIPT_PREFIX}webhook-message`).value.trim()
};
await GM_setValue(SCRIPT_PREFIX + 'settings', newSettings);
alert('Settings saved!');
}
// --- WATCHLIST MANAGEMENT ---
async function getWatchedUsers() { return await GM_getValue(SCRIPT_PREFIX + 'watched_users', {}); }
async function saveWatchedUsers(users) { await GM_setValue(SCRIPT_PREFIX + 'watched_users', users); }
async function toggleUserInWatchlist(userId, userName) {
const users = await getWatchedUsers();
if (users[userId]) { delete users[userId]; }
else { users[userId] = { name: userName, discord: false }; }
await saveWatchedUsers(users);
return !!users[userId];
}
// --- SETTINGS UI ---
async function renderWatchlist(container) {
if (!container) return;
try {
const users = await getWatchedUsers();
if (Object.keys(users).length === 0) {
container.innerHTML = `<p class="${SCRIPT_PREFIX}no-users">No users in watchlist. Visit a profile and click the watch button to add them.</p>`;
return;
}
let listHtml = '<ul>';
for (const [id, data] of Object.entries(users)) {
listHtml += `<li data-id="${id}"><a href="/profiles.php?XID=${id}" target="_blank">${data.name || 'Unknown User'}</a><div class="controls"><label>Discord Alerts: <input type="checkbox" class="${SCRIPT_PREFIX}discord-toggle" ${data.discord ? 'checked' : ''}></label><button class="${SCRIPT_PREFIX}remove-btn">Remove</button></div></li>`;
}
listHtml += '</ul>';
container.innerHTML = listHtml;
container.querySelectorAll(`.${SCRIPT_PREFIX}remove-btn`).forEach(btn => {
btn.onclick = async () => {
const id = btn.closest('li').dataset.id;
const currentUsers = await getWatchedUsers();
delete currentUsers[id];
await saveWatchedUsers(currentUsers);
await renderWatchlist(container);
if (getUserIdFromPage() === id) {
await updateButtonState();
startStopPageWatcher(false);
}
};
});
container.querySelectorAll(`.${SCRIPT_PREFIX}discord-toggle`).forEach(chk => {
chk.onchange = async () => {
const id = chk.closest('li').dataset.id;
const currentUsers = await getWatchedUsers();
if (currentUsers[id]) {
currentUsers[id].discord = chk.checked;
await saveWatchedUsers(currentUsers);
}
};
});
} catch (error) {
console.error('[DMW] Error rendering watchlist:', error);
container.innerHTML = `<p class="${SCRIPT_PREFIX}no-users">Error loading watchlist.</p>`;
}
}
async function populateSettingsModal(modal) {
try {
const settings = await loadSettings();
modal.querySelector(`#${SCRIPT_PREFIX}auto-attack`).checked = settings.autoAttack;
modal.querySelector(`#${SCRIPT_PREFIX}webhook-url`).value = settings.webhookUrl || '';
modal.querySelector(`#${SCRIPT_PREFIX}webhook-message`).value = settings.webhookMessage || '';
await renderWatchlist(modal.querySelector(`#${SCRIPT_PREFIX}watchlist-container`));
} catch (e) {
console.error('[DMW] Failed to populate settings modal:', e);
modal.querySelector(`#${SCRIPT_PREFIX}watchlist-container`).innerHTML = `<p class="${SCRIPT_PREFIX}no-users">Error loading settings.</p>`;
}
}
function createSettingsModal() {
document.getElementById(`${SCRIPT_PREFIX}settings-modal`)?.remove();
const modal = document.createElement('div');
modal.id = `${SCRIPT_PREFIX}settings-modal`;
modal.className = `${SCRIPT_PREFIX}modal`;
modal.style.display = 'block';
modal.innerHTML = `
<div class="${SCRIPT_PREFIX}modal-content">
<span class="${SCRIPT_PREFIX}close-button">×</span>
<h2 class="${SCRIPT_PREFIX}modal-header">${ICONS.settings} Deadman Watcher Settings</h2>
<div class="${SCRIPT_PREFIX}section">
<h3>Alerts & Actions</h3>
<div class="${SCRIPT_PREFIX}form-group ${SCRIPT_PREFIX}checkbox-group">
<input type="checkbox" id="${SCRIPT_PREFIX}auto-attack">
<label for="${SCRIPT_PREFIX}auto-attack">Open attack page when a watched target is alive</label>
</div>
<div class="${SCRIPT_PREFIX}form-group">
<label for="${SCRIPT_PREFIX}webhook-url">Discord Webhook URL:</label>
<input type="text" id="${SCRIPT_PREFIX}webhook-url">
</div>
<div class="${SCRIPT_PREFIX}form-group">
<label for="${SCRIPT_PREFIX}webhook-message">Custom Webhook Message (use %USERNAME% and %USERID%):</label>
<textarea id="${SCRIPT_PREFIX}webhook-message" rows="3" placeholder="Leave blank for a random default message."></textarea>
</div>
</div>
<div class="${SCRIPT_PREFIX}section">
<h3>Watchlist</h3>
<div id="${SCRIPT_PREFIX}watchlist-container"><div class="${SCRIPT_PREFIX}no-users">Loading...</div></div>
</div>
<div class="${SCRIPT_PREFIX}footer">
<a href="https://www.torn.com/profiles.php?XID=3626448" target="_blank">Made by HeyItzWerty [3626448]</a>
<button id="${SCRIPT_PREFIX}save-button">Save Settings</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelector(`.${SCRIPT_PREFIX}close-button`).onclick = () => modal.remove();
modal.querySelector(`#${SCRIPT_PREFIX}save-button`).onclick = async () => { await saveSettings(); modal.remove(); };
populateSettingsModal(modal).catch(console.error);
}
GM_registerMenuCommand("Deadman Watcher Settings", createSettingsModal);
// --- CORE FUNCTIONALITY ---
function sendWebhookNotification(userName, userId, settings, watchedUser) {
if (!settings.webhookUrl || !watchedUser.discord) return;
let messageContent = settings.webhookMessage;
if (!messageContent) messageContent = DEFAULT_MESSAGES[Math.floor(Math.random() * DEFAULT_MESSAGES.length)];
const content = messageContent.replace(/%USERNAME%/g, userName).replace(/%USERID%/g, userId);
GM_xmlhttpRequest({
method: "POST", url: settings.webhookUrl, headers: { "Content-Type": "application/json" },
data: JSON.stringify({ content }),
onload: () => console.log(`[DMW] Webhook notification for ${userName}.`),
onerror: (e) => console.error(`[DMW] Webhook failed for ${userName}:`, e)
});
}
// --- TITLE AND NOTIFICATION LOGIC ---
function stopAllTimers() {
if (flashInterval) clearInterval(flashInterval);
if (titleUpdateInterval) clearInterval(titleUpdateInterval);
flashInterval = null;
titleUpdateInterval = null;
}
function startFlashing(name) {
stopAllTimers();
let state = false;
const flashTitle1 = `🟢 ${name} is alive! 🟢`;
const flashTitle2 = `✅ ATTACK NOW! ✅`;
flashInterval = setInterval(() => {
document.title = state ? flashTitle1 : flashTitle2;
state = !state;
}, 800);
}
function parseHospitalTime(timeString) {
let totalSeconds = 0;
const hourMatch = timeString.match(/(\d+)\s+hours?/);
const minuteMatch = timeString.match(/(\d+)\s+minutes?/);
const secondMatch = timeString.match(/(\d+)\s+seconds?/); // Added to handle seconds as well
if (hourMatch) totalSeconds += parseInt(hourMatch[1], 10) * 3600;
if (minuteMatch) totalSeconds += parseInt(minuteMatch[1], 10) * 60;
if (secondMatch) totalSeconds += parseInt(secondMatch[1], 10);
return totalSeconds;
}
function formatSecondsToHMS(secs) {
const hours = Math.floor(secs / 3600);
const minutes = Math.floor((secs % 3600) / 60);
const seconds = Math.floor(secs % 60);
return [hours, minutes, seconds].map(v => v.toString().padStart(2, '0')).join(':');
}
// --- THIS FUNCTION CONTAINS THE FIX ---
function updateTimerTitle() {
const elapsed = (Date.now() - hospitalStartTime) / 1000;
// FIX: Calculate remaining time by subtracting elapsed time from the initial duration.
let remainingSeconds = hospitalInitialSeconds - elapsed;
// Prevent the timer from showing negative numbers if there's a slight delay.
if (remainingSeconds < 0) {
remainingSeconds = 0;
}
const formattedTime = formatSecondsToHMS(remainingSeconds);
const userName = getUserNameFromPage();
document.title = `🔴 ${userName} [${formattedTime}] 🔴`;
}
async function handleStatusChange(targetNode) {
const isHospitalized = targetNode.classList.contains('hospital');
const wasHospitalized = (targetNode.dataset.lastStatus === 'hospital');
const userName = getUserNameFromPage();
const userId = getUserIdFromPage();
stopAllTimers(); // Clear any previous timers before deciding what to do next
if (isHospitalized) {
const statusDescElement = targetNode.querySelector('.description .main-desc');
if (statusDescElement) {
hospitalInitialSeconds = parseHospitalTime(statusDescElement.textContent);
hospitalStartTime = Date.now();
updateTimerTitle(); // Set initial title
titleUpdateInterval = setInterval(updateTimerTitle, 1000);
}
} else {
document.title = `🟢 ${userName} [Alive] 🟢`;
if (wasHospitalized) {
console.log(`[DMW] ${userName} is now alive!`);
const settings = await loadSettings();
const watchedUsers = await getWatchedUsers();
const watchedUser = watchedUsers[userId];
if (!watchedUser) return;
startFlashing(userName);
sendWebhookNotification(userName, userId, settings, watchedUser);
if (settings.autoAttack) {
GM_openInTab(`https://www.torn.com/loader2.php?sid=getInAttack&user2ID=${userId}`, { active: true });
}
}
}
targetNode.dataset.lastStatus = isHospitalized ? 'hospital' : 'ok';
}
function startStopPageWatcher(isWatching) {
if (statusObserver) statusObserver.disconnect();
stopAllTimers();
if (isWatching) {
const statusDiv = document.querySelector('.profile-status');
if (statusDiv) {
statusObserver = new MutationObserver((mutations) => {
mutations.forEach(m => (m.type === 'attributes' && m.attributeName === 'class') && handleStatusChange(m.target));
});
statusObserver.observe(statusDiv, { attributes: true });
handleStatusChange(statusDiv); // Initial check
}
} else {
document.title = originalTitle;
}
}
// --- UI INJECTION & EVENT HANDLING ---
async function updateButtonState() {
const watchButton = document.getElementById(`${SCRIPT_PREFIX}watch-button`);
if (!watchButton) return;
const isWatched = !!(await getWatchedUsers())[getUserIdFromPage()];
const icon = watchButton.querySelector('svg path');
watchButton.classList.toggle('active', isWatched);
if (icon) icon.style.fill = isWatched ? TORN_GREEN : TORN_GREY;
}
function injectButton(actionsContainer) {
const descContainer = document.getElementById('profile-container-description');
if (!descContainer) return;
originalActionText = descContainer.textContent;
const userId = getUserIdFromPage();
const userName = getUserNameFromPage();
const watchButton = document.createElement('a');
watchButton.id = `${SCRIPT_PREFIX}watch-button`;
watchButton.href = '#';
watchButton.className = 'profile-button profile-button-watch';
watchButton.innerHTML = ICONS.gravestone;
watchButton.addEventListener('click', async (e) => {
e.preventDefault();
const isNowWatching = await toggleUserInWatchlist(userId, userName);
startStopPageWatcher(isNowWatching);
updateButtonState();
});
watchButton.addEventListener('mouseover', async () => {
const watchedUsers = await getWatchedUsers();
descContainer.textContent = watchedUsers[userId] ? `Disable Deadman Watcher for ${userName}` : `Enable Deadman Watcher for ${userName}`;
});
watchButton.addEventListener('mouseout', () => { descContainer.textContent = originalActionText; });
actionsContainer.appendChild(watchButton);
}
// --- INITIALIZATION ---
async function initialize() {
try {
if (!window.location.pathname.startsWith('/profiles.php')) return;
GM_addStyle(`
.profile-button-watch svg path { fill: ${TORN_GREY}; transition: fill 0.2s ease-in-out; }
.profile-button-watch.active svg path { fill: ${TORN_GREEN}; }
.${SCRIPT_PREFIX}modal { display: none; position: fixed; z-index: 9999; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.7); font-family: 'Signika', sans-serif; }
.${SCRIPT_PREFIX}modal-content { background-color: #333; color: #d4d4d4; margin: 10% auto; padding: 25px; border: 1px solid #444; width: 90%; max-width: 600px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.5); position: relative; }
.${SCRIPT_PREFIX}close-button { color: #aaa; float: right; font-size: 28px; font-weight: bold; line-height: 1; }
.${SCRIPT_PREFIX}close-button:hover, .${SCRIPT_PREFIX}close-button:focus { color: #fff; text-decoration: none; cursor: pointer; }
.${SCRIPT_PREFIX}modal-header { margin-top: 0; border-bottom: 1px solid #555; padding-bottom: 15px; display: flex; align-items: center; gap: 10px; }
.${SCRIPT_PREFIX}section { border: 1px solid #444; border-radius: 5px; padding: 15px; margin-bottom: 20px; background-color: #2d2d2d; }
.${SCRIPT_PREFIX}section h3 { margin-top: 0; color: #eee; }
.${SCRIPT_PREFIX}form-group { margin-bottom: 15px; }
.${SCRIPT_PREFIX}form-group label { display: block; margin-bottom: 8px; font-weight: bold; color: #ccc; }
.${SCRIPT_PREFIX}form-group input[type="text"], .${SCRIPT_PREFIX}form-group textarea { width: 100%; padding: 10px; border: 1px solid #555; border-radius: 4px; box-sizing: border-box; background-color: #222; color: #d4d4d4; }
.${SCRIPT_PREFIX}form-group textarea { resize: vertical; }
.${SCRIPT_PREFIX}checkbox-group { display: flex; align-items: center; margin-bottom: 10px; }
.${SCRIPT_PREFIX}checkbox-group input { margin-right: 10px; width: 18px; height: 18px; flex-shrink: 0; accent-color: ${TORN_GREEN}; }
.${SCRIPT_PREFIX}checkbox-group label { margin-bottom: 0; }
.${SCRIPT_PREFIX}footer { display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid #555; }
.${SCRIPT_PREFIX}footer a { color: #999; text-decoration: none; font-size: 0.9em; }
.${SCRIPT_PREFIX}footer a:hover { text-decoration: underline; }
#${SCRIPT_PREFIX}save-button { background-color: #555; color: white; padding: 10px 18px; border: 1px solid #777; border-radius: 4px; cursor: pointer; font-size: 16px; }
#${SCRIPT_PREFIX}save-button:hover { background-color: #666; }
#${SCRIPT_PREFIX}watchlist-container ul { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; }
#${SCRIPT_PREFIX}watchlist-container li { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #444; }
#${SCRIPT_PREFIX}watchlist-container li:last-child { border-bottom: none; }
#${SCRIPT_PREFIX}watchlist-container a { color: ${TORN_GREEN}; text-decoration: none; }
#${SCRIPT_PREFIX}watchlist-container .controls { display: flex; align-items: center; gap: 15px; }
#${SCRIPT_PREFIX}watchlist-container .controls label { display: flex; align-items: center; gap: 5px; cursor: pointer; font-size: 0.9em; }
#${SCRIPT_PREFIX}watchlist-container .controls input[type="checkbox"] { accent-color: ${TORN_GREEN}; }
.${SCRIPT_PREFIX}remove-btn { background: #800; color: #fff; border: 1px solid #a00; border-radius: 3px; padding: 3px 8px; cursor: pointer; font-size: 0.8em; }
.${SCRIPT_PREFIX}remove-btn:hover { background: #a00; }
.${SCRIPT_PREFIX}no-users { color: #999; font-style: italic; padding: 10px; text-align: center; }
`);
const observer = new MutationObserver(async (mutations, obs) => {
const actionsContainer = document.querySelector('.profile-action .buttons-list');
if (actionsContainer && !document.getElementById(`${SCRIPT_PREFIX}watch-button`)) {
obs.disconnect();
injectButton(actionsContainer);
const watchedUsers = await getWatchedUsers();
if (watchedUsers[getUserIdFromPage()]) {
startStopPageWatcher(true);
}
await updateButtonState();
}
});
const profileRoot = document.getElementById('profileroot');
if (profileRoot) { observer.observe(profileRoot, { childList: true, subtree: true }); }
} catch (e) {
console.error('[DMW] A critical error occurred during initialization:', e);
}
}
function getUserIdFromPage() {
try { return new URLSearchParams(window.location.search).get('XID'); } catch (e) { return null; }
}
function getUserNameFromPage() {
const nameElement = document.querySelector('h4#skip-to-content');
if (!nameElement) return 'User';
return nameElement.textContent.split(' [')[0].trim();
}
initialize();
})();