War target tools: travel warnings, available targets, med targets, sorting, shortlists
// ==UserScript==
// @name Torn War Tools
// @namespace https://www.torn.com/
// @version 2.15
// @description War target tools: travel warnings, available targets, med targets, sorting, shortlists
// @author Systoned
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// ==/UserScript==
(function () {
'use strict';
// -- Settings ---------------------------------------------------------
function getApiKey() { return GM_getValue('wtw_api_key', ''); }
function setApiKey(v) { GM_setValue('wtw_api_key', v.trim()); }
function getEnemyFactionId() { return GM_getValue('wtw_enemy_faction_id', ''); }
function setEnemyFactionId(v) { GM_setValue('wtw_enemy_faction_id', String(v).trim()); }
function getEnemyFactionName() { return GM_getValue('wtw_enemy_faction_name', ''); }
function isConfigured() { return getApiKey() !== ''; }
// -- Shortlist --------------------------------------------------------
function getShortlistKey() { return 'wtw_shortlist_' + getEnemyFactionId(); }
function getShortlist() {
try { return JSON.parse(GM_getValue(getShortlistKey(), '[]')); }
catch (e) { return []; }
}
function saveShortlist(list) { GM_setValue(getShortlistKey(), JSON.stringify(list)); }
function isShortlisted(id) { return getShortlist().indexOf(id) !== -1; }
function toggleShortlist(id) {
var list = getShortlist();
var idx = list.indexOf(id);
if (idx === -1) { list.push(id); } else { list.splice(idx, 1); }
saveShortlist(list);
}
function clearShortlistForFaction(factionId) {
GM_setValue('wtw_shortlist_' + factionId, '[]');
}
// -- Cache ------------------------------------------------------------
var memberCache = { data: null, time: 0 };
var CACHE_TTL = 60000; // 60 seconds
var WAR_CHECK_TTL = 1800000; // 30 minutes
function getCachedMembers() {
if (memberCache.data && (Date.now() - memberCache.time) < CACHE_TTL) {
return Promise.resolve(memberCache.data);
}
var ensure = getEnemyFactionId() ? Promise.resolve() : resolveEnemyFaction();
return ensure.then(fetchEnemyMembers).then(function (members) {
memberCache.data = members;
memberCache.time = Date.now();
return members;
});
}
function prefetchMembers() {
if (!isConfigured()) return;
maybeRefreshWar().then(function () {
getCachedMembers().catch(function () {});
});
}
// -- API --------------------------------------------------------------
function apiGet(path) {
return new Promise(function (resolve, reject) {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.torn.com' + path,
headers: {
'Authorization': 'ApiKey ' + getApiKey(),
'Accept': 'application/json'
},
onload: function (r) {
try {
var data = JSON.parse(r.responseText);
if (data.error) { reject(data.error.error || 'API error'); return; }
resolve(data);
} catch (e) { reject('Parse error'); }
},
onerror: function () { reject('Network error'); }
});
});
}
function fetchEnemyMembers() {
return apiGet('/v2/faction/' + getEnemyFactionId() + '/members?striptags=true')
.then(function (data) { return data.members || []; });
}
// Resolve the enemy faction from the current ranked war:
// 1. /v2/faction/basic -> own faction id
// 2. /v2/faction/wars -> ranked war factions; the one that isn't ours is the enemy
function resolveEnemyFaction() {
return apiGet('/v2/faction/basic').then(function (data) {
var ownId = data.basic && data.basic.id;
if (!ownId) { return Promise.reject('Could not read own faction from API key'); }
return apiGet('/v2/faction/wars').then(function (w) {
var ranked = w.wars && w.wars.ranked;
if (!ranked || !ranked.factions || ranked.factions.length === 0) {
return Promise.reject('No active ranked war found');
}
for (var i = 0; i < ranked.factions.length; i++) {
var f = ranked.factions[i];
if (f.id !== ownId) {
var old = getEnemyFactionId();
if (old && old !== String(f.id)) {
clearShortlistForFaction(old);
memberCache.data = null;
memberCache.time = 0;
}
setEnemyFactionId(f.id);
GM_setValue('wtw_enemy_faction_name', f.name || '');
GM_setValue('wtw_war_checked', Date.now());
return f.id;
}
}
return Promise.reject('Enemy faction not found in war data');
});
});
}
function maybeRefreshWar() {
var last = GM_getValue('wtw_war_checked', 0);
if (!getEnemyFactionId() || (Date.now() - last) > WAR_CHECK_TTL) {
return resolveEnemyFaction().catch(function () {});
}
return Promise.resolve();
}
// -- Threat / Target Logic --------------------------------------------
// Each traveling/abroad member's destination is parsed out of their status
// description, then compared country-to-country with the clicked
// destination, instead of searching for the clicked name as a substring.
// Alias map covers countries where the travel page and the API use
// different names.
var CANONICAL_COUNTRY = {
'uae': 'united arab emirates',
'uk': 'united kingdom'
};
function normalizeCountry(name) {
var n = String(name).toLowerCase().trim();
if (n.indexOf('the ') === 0) n = n.substring(4);
return CANONICAL_COUNTRY[n] || n;
}
// Returns { type: 'traveling' | 'abroad' | 'returning', country } or null
// Traveling descriptions are "Traveling from X to Y": destination is Y,
// and Y = Torn means they're coming home (excluded from warnings).
function parseTravelStatus(m) {
var state = m.status && m.status.state;
var desc = (m.status && m.status.description) || '';
var dl = desc.toLowerCase();
if (state === 'Traveling') {
var f = dl.indexOf('traveling from ');
if (f !== -1) {
var rest = dl.substring(f + 15);
var toIdx = rest.lastIndexOf(' to ');
if (toIdx !== -1) {
var origin = rest.substring(0, toIdx).trim();
var dest = rest.substring(toIdx + 4).trim();
if (dest === 'torn') return { type: 'returning', country: origin };
return { type: 'traveling', country: dest };
}
}
// Older formats, kept as fallbacks
var r = dl.indexOf('returning to torn from ');
if (r !== -1) return { type: 'returning', country: dl.substring(r + 23).trim() };
var t = dl.indexOf('traveling to ');
if (t !== -1) return { type: 'traveling', country: dl.substring(t + 13).trim() };
return null;
}
if (state === 'Abroad') {
if (dl.indexOf('in ') === 0) return { type: 'abroad', country: dl.substring(3).trim() };
return { type: 'abroad', country: dl };
}
return null;
}
function findThreats(members, countryName) {
var threats = [];
var want = normalizeCountry(countryName);
for (var i = 0; i < members.length; i++) {
var m = members[i];
var p = parseTravelStatus(m);
if (!p) continue;
var have = normalizeCountry(p.country);
if (have === want || have.indexOf(want) !== -1 || want.indexOf(have) !== -1) {
threats.push({
id: m.id, name: m.name, level: m.level,
type: p.type, description: m.status.description
});
}
}
return threats;
}
var currentSort = 'level'; // 'level' or 'activity'
var sortAsc = false; // false = descending (default), true = ascending
var activityOrder = { 'Online': 0, 'Idle': 1, 'Offline': 2, 'Unknown': 3 };
function parseRelativeTime(str) {
// Convert "X minutes ago", "X hours ago" etc to seconds
if (!str || str === 'Unknown') return Infinity;
var match = str.match(/(\d+)\s*(second|minute|hour|day|week|month|year)/i);
if (!match) return Infinity;
var num = parseInt(match[1], 10);
var unit = match[2].toLowerCase();
var multipliers = { second: 1, minute: 60, hour: 3600, day: 86400, week: 604800, month: 2592000, year: 31536000 };
return num * (multipliers[unit] || 1);
}
function getAvailableTargets(members, sortMode, ascending) {
var targets = [];
for (var i = 0; i < members.length; i++) {
var m = members[i];
var state = m.status && m.status.state;
if (state === 'Okay') {
targets.push({
id: m.id, name: m.name, level: m.level,
lastAction: m.last_action ? m.last_action.relative : 'Unknown',
actionStatus: m.last_action ? m.last_action.status : 'Unknown',
lastActionSeconds: parseRelativeTime(m.last_action ? m.last_action.relative : 'Unknown')
});
}
}
if (sortMode === 'activity') {
targets.sort(function (a, b) {
var ao = activityOrder[a.actionStatus] !== undefined ? activityOrder[a.actionStatus] : 3;
var bo = activityOrder[b.actionStatus] !== undefined ? activityOrder[b.actionStatus] : 3;
if (ao !== bo) return ascending ? bo - ao : ao - bo;
// Within same status group, sort by recency
var recency = a.lastActionSeconds - b.lastActionSeconds;
return ascending ? -recency : recency;
});
} else {
targets.sort(function (a, b) {
return ascending ? a.level - b.level : b.level - a.level;
});
}
// Pin shortlisted to top
var shortlist = getShortlist();
if (shortlist.length > 0) {
var pinned = [];
var rest = [];
for (var j = 0; j < targets.length; j++) {
if (shortlist.indexOf(targets[j].id) !== -1) {
pinned.push(targets[j]);
} else {
rest.push(targets[j]);
}
}
targets = pinned.concat(rest);
}
return targets;
}
function getMedTargets(members) {
var targets = [];
var now = Math.floor(Date.now() / 1000);
for (var i = 0; i < members.length; i++) {
var m = members[i];
var state = m.status && m.status.state;
if (state === 'Hospital') {
var until = m.status.until || 0;
var remaining = until - now;
if (remaining < 0) remaining = 0;
targets.push({
id: m.id, name: m.name, level: m.level,
description: m.status.description || '',
until: until,
remaining: remaining
});
}
}
targets.sort(function (a, b) { return a.remaining - b.remaining; });
return targets;
}
function formatTime(seconds) {
if (seconds <= 0) return 'Now';
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = seconds % 60;
var parts = [];
if (h > 0) parts.push(h + 'h');
if (m > 0) parts.push(m + 'm');
if (h === 0) parts.push(s + 's');
return parts.join(' ');
}
// -- Styles -----------------------------------------------------------
// Torn-native look: light/dark aware, black gradient title bars,
// torn-btn used for buttons where possible.
function injectStyles() {
var style = document.createElement('style');
style.textContent = [
// Overlay
'.wtw-overlay {',
' position: fixed;',
' top: 0; left: 0; right: 0; bottom: 0;',
' background: rgba(0, 0, 0, 0.65);',
' z-index: 999999;',
' display: flex;',
' align-items: center;',
' justify-content: center;',
' padding: 20px;',
'}',
// Modal panel
'.wtw-modal {',
' background: #f2f2f2;',
' border-radius: 5px;',
' max-width: 500px;',
' width: 100%;',
' max-height: 80vh;',
' display: flex;',
' flex-direction: column;',
' overflow: hidden;',
' color: #333;',
' font-family: Arial, Helvetica, sans-serif;',
' font-size: 12px;',
' box-shadow: 0 0 15px rgba(0, 0, 0, 0.6);',
'}',
'body.dark-mode .wtw-modal { background: #2e2e2e; color: #ccc; }',
// Title bar (Torn black gradient)
'.wtw-title {',
' background: linear-gradient(180deg, #666 0%, #333 50%, #111 100%);',
' color: #fff;',
' font-weight: bold;',
' padding: 6px 10px;',
' display: flex;',
' justify-content: space-between;',
' align-items: center;',
' text-shadow: 0 1px 0 #000;',
' border-radius: 5px 5px 0 0;',
'}',
'.wtw-title-warn {',
' background: linear-gradient(180deg, #a33 0%, #611 50%, #300 100%);',
'}',
'.wtw-close {',
' background: none;',
' border: none;',
' color: #bbb;',
' font-size: 16px;',
' cursor: pointer;',
' padding: 0 2px;',
' line-height: 1;',
'}',
'.wtw-close:hover { color: #fff; }',
// Body
'.wtw-body { padding: 10px; overflow-y: auto; }',
// Buttons (fallback styling in case torn-btn is unavailable)
'.wtw-btn { cursor: pointer; }',
'.wtw-actions { display: flex; gap: 8px; margin: 0 0 10px 0; flex-wrap: wrap; }',
'.wtw-actions .wtw-btn { flex: 1; min-width: 120px; }',
'.wtw-btn.wtw-active { box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.5); }',
'.wtw-form-buttons { display: flex; gap: 8px; margin-top: 12px; }',
// Settings form
'.wtw-form-row { margin: 0 0 10px 0; }',
'.wtw-form-row label {',
' display: block;',
' color: #666;',
' margin: 0 0 3px 0;',
' font-size: 11px;',
'}',
'body.dark-mode .wtw-form-row label { color: #999; }',
'.wtw-form-row input {',
' width: 100%;',
' box-sizing: border-box;',
' background: #fff;',
' border: 1px solid #ccc;',
' color: #333;',
' padding: 5px 8px;',
' border-radius: 5px;',
' font-size: 12px;',
' font-family: monospace;',
'}',
'body.dark-mode .wtw-form-row input { background: #222; border-color: #444; color: #ccc; }',
'.wtw-form-row input:focus { outline: none; border-color: #888; }',
'.wtw-form-hint { font-size: 11px; color: #888; margin-top: 6px; }',
// Settings bar (shown when configured)
'.wtw-settings-bar {',
' display: flex;',
' align-items: center;',
' justify-content: space-between;',
' padding: 0 0 8px 0;',
' margin: 0 0 10px 0;',
' border-bottom: 1px solid #ccc;',
' font-size: 11px;',
'}',
'body.dark-mode .wtw-settings-bar { border-bottom-color: #444; }',
'.wtw-settings-left { display: flex; align-items: center; gap: 6px; color: #666; }',
'body.dark-mode .wtw-settings-left { color: #999; }',
'.wtw-settings-right { display: flex; gap: 6px; }',
'.wtw-dot { width: 8px; height: 8px; border-radius: 50%; background: #85b200; }',
'.wtw-settings-edit {',
' background: none;',
' border: 1px solid #ccc;',
' color: #666;',
' padding: 2px 8px;',
' border-radius: 5px;',
' cursor: pointer;',
' font-size: 11px;',
'}',
'.wtw-settings-edit:hover { border-color: #888; color: #333; }',
'body.dark-mode .wtw-settings-edit { border-color: #444; color: #999; }',
'body.dark-mode .wtw-settings-edit:hover { border-color: #666; color: #ccc; }',
// Cooldown strip
'.wtw-cooldowns { display: flex; gap: 8px; margin: 0 0 10px 0; }',
'.wtw-cd {',
' flex: 1;',
' text-align: center;',
' border: 1px solid #ccc;',
' border-radius: 5px;',
' padding: 5px 4px;',
' background: #fff;',
'}',
'body.dark-mode .wtw-cd { border-color: #444; background: #333; }',
'.wtw-cd-label { font-size: 10px; color: #888; text-transform: uppercase; }',
'.wtw-cd-time { font-weight: bold; margin-top: 2px; }',
'.wtw-cd-ready { color: #678c00; }',
'body.dark-mode .wtw-cd-ready { color: #8abe3c; }',
// Target list (Torn panel style: bordered rounded box, divided rows)
'.wtw-list {',
' margin: 0;',
' padding: 0;',
' list-style: none;',
' border: 1px solid #ccc;',
' border-radius: 5px;',
' overflow: hidden;',
' background: #fff;',
'}',
'body.dark-mode .wtw-list { border-color: #444; background: #333; }',
'.wtw-list-item {',
' padding: 6px 10px;',
' border-bottom: 1px solid #e5e5e5;',
' border-left: 3px solid transparent;',
'}',
'.wtw-list-item:last-child { border-bottom: none; }',
'body.dark-mode .wtw-list-item { border-bottom-color: #444; }',
'.wtw-list-item-ok { border-left-color: #85b200; }',
'.wtw-list-item-hosp { border-left-color: #cc8800; }',
'.wtw-list-item-threat-traveling { border-left-color: #cc8800; }',
'.wtw-list-item-threat-abroad { border-left-color: #cc3333; }',
'.wtw-list-item-threat-returning { border-left-color: #4a7ba6; }',
'.wtw-list-name { font-weight: bold; }',
'.wtw-list-name a { color: #069; text-decoration: none; }',
'.wtw-list-name a:hover { text-decoration: underline; }',
'body.dark-mode .wtw-list-name a { color: #7ab5e2; }',
'.wtw-list-detail { color: #888; font-size: 11px; margin-top: 2px; }',
'body.dark-mode .wtw-list-detail { color: #999; }',
// Tags
'.wtw-tag {',
' display: inline-block;',
' padding: 1px 6px;',
' border-radius: 3px;',
' font-size: 10px;',
' font-weight: bold;',
' margin-left: 6px;',
'}',
'.wtw-tag-online { background: #e4efd0; color: #678c00; }',
'.wtw-tag-idle { background: #f5e9cc; color: #a67c00; }',
'.wtw-tag-offline { background: #e5e5e5; color: #888; }',
'.wtw-tag-traveling { background: #f5e9cc; color: #a67c00; }',
'.wtw-tag-abroad { background: #f2d5d5; color: #b32d2d; }',
'.wtw-tag-returning { background: #d8e4ef; color: #33658a; }',
'body.dark-mode .wtw-tag-online { background: #1a331a; color: #8abe3c; }',
'body.dark-mode .wtw-tag-idle { background: #332e1a; color: #cc8800; }',
'body.dark-mode .wtw-tag-offline { background: #2a2a2a; color: #888; }',
'body.dark-mode .wtw-tag-traveling { background: #332200; color: #cc8800; }',
'body.dark-mode .wtw-tag-abroad { background: #331111; color: #e05555; }',
'body.dark-mode .wtw-tag-returning { background: #16222e; color: #6da4d4; }',
// Status / empty / count
'.wtw-status { text-align: center; color: #888; padding: 20px 0; }',
'.wtw-error { color: #b32d2d; }',
'body.dark-mode .wtw-error { color: #e05555; }',
'.wtw-count { color: #888; font-size: 11px; margin: 0 0 8px 0; }',
// Travel warning
'.wtw-warn-sub { color: #888; font-size: 12px; margin: 0 0 10px 0; }',
'.wtw-dismiss { display: block; width: 100%; margin-top: 12px; }',
// Sidebar icon
'.wtw-sidebar-icon path { fill: currentColor; }',
// Sort controls
'.wtw-sort-bar {',
' display: flex;',
' align-items: center;',
' gap: 6px;',
' margin: 0 0 8px 0;',
' font-size: 11px;',
'}',
'.wtw-sort-label { color: #888; }',
'.wtw-sort-btn {',
' background: none;',
' border: 1px solid #ccc;',
' color: #666;',
' padding: 2px 8px;',
' border-radius: 5px;',
' cursor: pointer;',
' font-size: 11px;',
'}',
'.wtw-sort-btn:hover { border-color: #888; color: #333; }',
'.wtw-sort-btn.wtw-sort-active { border-color: #85b200; color: #678c00; }',
'body.dark-mode .wtw-sort-btn { border-color: #444; color: #999; }',
'body.dark-mode .wtw-sort-btn:hover { border-color: #666; color: #ccc; }',
'body.dark-mode .wtw-sort-btn.wtw-sort-active { border-color: #85b200; color: #8abe3c; }',
'.wtw-sort-arrow { margin-left: 3px; font-size: 9px; }',
// Shortlist checkbox
'.wtw-shortlist-cb {',
' float: right;',
' cursor: pointer;',
' width: 14px;',
' height: 14px;',
' accent-color: #85b200;',
' margin: 2px 0 0 0;',
'}',
// Shortlist header
'.wtw-list-header {',
' display: flex;',
' justify-content: space-between;',
' font-size: 10px;',
' color: #888;',
' padding: 0 10px 4px 10px;',
' text-transform: uppercase;',
'}',
// Pinned separator
'.wtw-pinned-sep {',
' border: none;',
' border-top: 1px dashed #ccc;',
' margin: 0;',
'}',
'body.dark-mode .wtw-pinned-sep { border-top-color: #555; }'
].join('\n');
document.head.appendChild(style);
}
function makeButton(text, extraClass) {
var btn = document.createElement('button');
btn.className = 'torn-btn wtw-btn' + (extraClass ? ' ' + extraClass : '');
btn.textContent = text;
return btn;
}
// -- Modal Framework --------------------------------------------------
var currentOverlay = null;
var cooldownTimer = null;
function stopCooldownTimer() {
if (cooldownTimer) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}
function closeModal() {
stopCooldownTimer();
if (currentOverlay) {
currentOverlay.remove();
currentOverlay = null;
}
}
// Creates overlay + modal with a Torn-style title bar. Returns the body element.
function createModal(titleText, warnStyle) {
closeModal();
var overlay = document.createElement('div');
overlay.className = 'wtw-overlay';
overlay.addEventListener('click', function (e) {
if (e.target === overlay) closeModal();
});
var modal = document.createElement('div');
modal.className = 'wtw-modal';
var title = document.createElement('div');
title.className = 'wtw-title' + (warnStyle ? ' wtw-title-warn' : '');
var titleSpan = document.createElement('span');
titleSpan.textContent = titleText;
var closeBtn = document.createElement('button');
closeBtn.className = 'wtw-close';
closeBtn.textContent = '\u00D7';
closeBtn.addEventListener('click', closeModal);
title.appendChild(titleSpan);
title.appendChild(closeBtn);
var body = document.createElement('div');
body.className = 'wtw-body';
modal.appendChild(title);
modal.appendChild(body);
overlay.appendChild(modal);
document.body.appendChild(overlay);
currentOverlay = overlay;
return body;
}
// -- Main War Modal ---------------------------------------------------
function openWarModal() {
var content = createModal('War Tools', false);
if (!isConfigured()) {
renderSettingsForm(content, false);
} else {
renderConfiguredView(content);
}
}
function renderConfiguredView(container) {
container.innerHTML = '';
// Enemy faction not yet resolved: detect from ranked war
if (!getEnemyFactionId()) {
var status = document.createElement('div');
status.className = 'wtw-status';
status.textContent = 'Detecting current ranked war...';
container.appendChild(status);
resolveEnemyFaction().then(function () {
renderConfiguredView(container);
}).catch(function (err) {
container.innerHTML = '';
var errEl = document.createElement('div');
errEl.className = 'wtw-status wtw-error';
errEl.textContent = 'Error: ' + err;
container.appendChild(errEl);
var buttons = document.createElement('div');
buttons.className = 'wtw-form-buttons';
var retryBtn = makeButton('Retry');
retryBtn.addEventListener('click', function () { renderConfiguredView(container); });
var editBtn = makeButton('Edit API Key');
editBtn.addEventListener('click', function () { renderSettingsForm(container, true); });
buttons.appendChild(retryBtn);
buttons.appendChild(editBtn);
container.appendChild(buttons);
});
return;
}
// Settings bar
var bar = document.createElement('div');
bar.className = 'wtw-settings-bar';
var left = document.createElement('div');
left.className = 'wtw-settings-left';
var dot = document.createElement('span');
dot.className = 'wtw-dot';
var info = document.createElement('span');
var name = getEnemyFactionName();
info.textContent = 'Enemy: ' + (name ? name + ' [' + getEnemyFactionId() + ']' : getEnemyFactionId());
left.appendChild(dot);
left.appendChild(info);
var right = document.createElement('div');
right.className = 'wtw-settings-right';
var refreshBtn = document.createElement('button');
refreshBtn.className = 'wtw-settings-edit';
refreshBtn.textContent = 'Refresh War';
refreshBtn.addEventListener('click', function () {
setEnemyFactionId('');
renderConfiguredView(container);
});
var editBtn = document.createElement('button');
editBtn.className = 'wtw-settings-edit';
editBtn.textContent = 'Edit Key';
editBtn.addEventListener('click', function () {
renderSettingsForm(container, true);
});
right.appendChild(refreshBtn);
right.appendChild(editBtn);
bar.appendChild(left);
bar.appendChild(right);
container.appendChild(bar);
// Cooldowns
renderCooldowns(container);
// Action buttons
var actions = document.createElement('div');
actions.className = 'wtw-actions';
var availBtn = makeButton('Available');
availBtn.addEventListener('click', function () {
setActiveBtn(availBtn);
loadAvailableTargets(listArea);
});
var travelBtn = makeButton('Traveling');
travelBtn.addEventListener('click', function () {
setActiveBtn(travelBtn);
loadTravelingTargets(listArea);
});
var medBtn = makeButton('Hosp');
medBtn.addEventListener('click', function () {
setActiveBtn(medBtn);
loadMedTargets(listArea);
});
actions.appendChild(availBtn);
actions.appendChild(travelBtn);
actions.appendChild(medBtn);
container.appendChild(actions);
var listArea = document.createElement('div');
listArea.id = 'wtw-list-area';
container.appendChild(listArea);
function setActiveBtn(active) {
var btns = actions.querySelectorAll('.wtw-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.remove('wtw-active');
}
active.classList.add('wtw-active');
}
}
function renderCooldowns(container) {
var strip = document.createElement('div');
strip.className = 'wtw-cooldowns';
container.appendChild(strip);
apiGet('/v2/user/cooldowns').then(function (data) {
var cd = data.cooldowns || {};
var now = Date.now();
var items = [
{ label: 'Drug', until: now + (cd.drug || 0) * 1000 },
{ label: 'Booster', until: now + (cd.booster || 0) * 1000 },
{ label: 'Med', until: now + (cd.medical || 0) * 1000 }
];
strip.innerHTML = '';
var timeEls = [];
for (var i = 0; i < items.length; i++) {
var box = document.createElement('div');
box.className = 'wtw-cd';
var lbl = document.createElement('div');
lbl.className = 'wtw-cd-label';
lbl.textContent = items[i].label;
var time = document.createElement('div');
time.className = 'wtw-cd-time';
box.appendChild(lbl);
box.appendChild(time);
strip.appendChild(box);
timeEls.push(time);
}
function tick() {
var n = Date.now();
for (var i = 0; i < items.length; i++) {
var remaining = Math.ceil((items[i].until - n) / 1000);
if (remaining <= 0) {
timeEls[i].textContent = 'Ready';
timeEls[i].classList.add('wtw-cd-ready');
} else {
timeEls[i].textContent = formatTime(remaining);
timeEls[i].classList.remove('wtw-cd-ready');
}
}
}
tick();
stopCooldownTimer();
cooldownTimer = setInterval(tick, 1000);
}).catch(function () {
strip.remove();
});
}
function renderSettingsForm(container, hasCancel) {
stopCooldownTimer();
container.innerHTML = '';
var row1 = document.createElement('div');
row1.className = 'wtw-form-row';
var lbl1 = document.createElement('label');
lbl1.textContent = 'Torn API Key';
var inp1 = document.createElement('input');
inp1.type = 'password';
inp1.placeholder = 'Enter your API key';
inp1.value = getApiKey();
row1.appendChild(lbl1);
row1.appendChild(inp1);
var hint = document.createElement('div');
hint.className = 'wtw-form-hint';
hint.textContent = 'Requires a Minimal access key. The enemy faction is detected automatically from your current ranked war.';
var buttons = document.createElement('div');
buttons.className = 'wtw-form-buttons';
var saveBtn = makeButton('Save');
saveBtn.addEventListener('click', function () {
var key = inp1.value.trim();
if (!key) { inp1.style.borderColor = '#cc3333'; return; }
setApiKey(key);
setEnemyFactionId('');
GM_setValue('wtw_war_checked', 0);
memberCache.data = null;
memberCache.time = 0;
renderConfiguredView(container);
});
buttons.appendChild(saveBtn);
if (hasCancel) {
var cancelBtn = makeButton('Cancel');
cancelBtn.addEventListener('click', function () {
renderConfiguredView(container);
});
buttons.appendChild(cancelBtn);
}
container.appendChild(row1);
container.appendChild(hint);
container.appendChild(buttons);
}
// -- List Renderers ---------------------------------------------------
function loadAvailableTargets(container) {
container.innerHTML = '';
var status = document.createElement('div');
status.className = 'wtw-status';
status.textContent = 'Loading...';
container.appendChild(status);
getCachedMembers().then(function (members) {
container.innerHTML = '';
var targets = getAvailableTargets(members, currentSort, sortAsc);
var shortlist = getShortlist();
// Sort bar
var sortBar = document.createElement('div');
sortBar.className = 'wtw-sort-bar';
var sortLabel = document.createElement('span');
sortLabel.className = 'wtw-sort-label';
sortLabel.textContent = 'Sort:';
sortBar.appendChild(sortLabel);
var levelBtn = document.createElement('button');
levelBtn.className = 'wtw-sort-btn' + (currentSort === 'level' ? ' wtw-sort-active' : '');
levelBtn.innerHTML = 'Level' + (currentSort === 'level' ? '<span class="wtw-sort-arrow">' + (sortAsc ? '▲' : '▼') + '</span>' : '');
levelBtn.addEventListener('click', function () {
if (currentSort === 'level') {
sortAsc = !sortAsc;
} else {
currentSort = 'level';
sortAsc = false;
}
loadAvailableTargets(container);
});
sortBar.appendChild(levelBtn);
var actBtn = document.createElement('button');
actBtn.className = 'wtw-sort-btn' + (currentSort === 'activity' ? ' wtw-sort-active' : '');
actBtn.innerHTML = 'Last Active' + (currentSort === 'activity' ? '<span class="wtw-sort-arrow">' + (sortAsc ? '▲' : '▼') + '</span>' : '');
actBtn.addEventListener('click', function () {
if (currentSort === 'activity') {
sortAsc = !sortAsc;
} else {
currentSort = 'activity';
sortAsc = false;
}
loadAvailableTargets(container);
});
sortBar.appendChild(actBtn);
container.appendChild(sortBar);
var count = document.createElement('div');
count.className = 'wtw-count';
var pinnedCount = 0;
for (var c = 0; c < targets.length; c++) {
if (shortlist.indexOf(targets[c].id) !== -1) pinnedCount++;
}
count.textContent = targets.length + ' available target' + (targets.length !== 1 ? 's' : '') +
(pinnedCount > 0 ? ' (' + pinnedCount + ' shortlisted)' : '');
container.appendChild(count);
if (targets.length === 0) {
var empty = document.createElement('div');
empty.className = 'wtw-status';
empty.textContent = 'No available targets';
container.appendChild(empty);
return;
}
// Column header
var listHeader = document.createElement('div');
listHeader.className = 'wtw-list-header';
var targetLabel = document.createElement('span');
targetLabel.textContent = 'Target';
var shortlistLabel = document.createElement('span');
shortlistLabel.textContent = 'Shortlist';
listHeader.appendChild(targetLabel);
listHeader.appendChild(shortlistLabel);
container.appendChild(listHeader);
var list = document.createElement('ul');
list.className = 'wtw-list';
var separatorAdded = false;
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
var isPinned = shortlist.indexOf(t.id) !== -1;
// Add separator after last pinned item
if (!isPinned && !separatorAdded && pinnedCount > 0) {
var sep = document.createElement('hr');
sep.className = 'wtw-pinned-sep';
list.appendChild(sep);
separatorAdded = true;
}
var li = document.createElement('li');
li.className = 'wtw-list-item wtw-list-item-ok';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'wtw-shortlist-cb';
cb.checked = isPinned;
cb.title = isPinned ? 'Remove from shortlist' : 'Add to shortlist';
(function (tid) {
cb.addEventListener('change', function () {
toggleShortlist(tid);
loadAvailableTargets(container);
});
})(t.id);
li.appendChild(cb);
var nameEl = document.createElement('div');
nameEl.className = 'wtw-list-name';
var link = document.createElement('a');
link.href = 'https://www.torn.com/profiles.php?XID=' + t.id;
link.target = '_blank';
link.textContent = t.name;
nameEl.appendChild(link);
var tag = document.createElement('span');
var tagClass = 'wtw-tag wtw-tag-' + t.actionStatus.toLowerCase();
tag.className = tagClass;
tag.textContent = t.actionStatus;
nameEl.appendChild(tag);
var detail = document.createElement('div');
detail.className = 'wtw-list-detail';
detail.textContent = 'Level ' + t.level + ' \u2014 ' + t.lastAction;
li.appendChild(nameEl);
li.appendChild(detail);
list.appendChild(li);
}
container.appendChild(list);
}).catch(function (err) {
container.innerHTML = '';
var errEl = document.createElement('div');
errEl.className = 'wtw-status wtw-error';
errEl.textContent = 'Error: ' + err;
container.appendChild(errEl);
});
}
function loadMedTargets(container) {
container.innerHTML = '';
var status = document.createElement('div');
status.className = 'wtw-status';
status.textContent = 'Loading...';
container.appendChild(status);
getCachedMembers().then(function (members) {
container.innerHTML = '';
var targets = getMedTargets(members);
var count = document.createElement('div');
count.className = 'wtw-count';
count.textContent = targets.length + ' hospitalised member' + (targets.length !== 1 ? 's' : '');
container.appendChild(count);
if (targets.length === 0) {
var empty = document.createElement('div');
empty.className = 'wtw-status';
empty.textContent = 'No members in hospital';
container.appendChild(empty);
return;
}
var list = document.createElement('ul');
list.className = 'wtw-list';
for (var i = 0; i < targets.length; i++) {
var t = targets[i];
var li = document.createElement('li');
li.className = 'wtw-list-item wtw-list-item-hosp';
var isPinned = isShortlisted(t.id);
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'wtw-shortlist-cb';
cb.checked = isPinned;
cb.title = isPinned ? 'Remove from shortlist' : 'Add to shortlist';
(function (tid) {
cb.addEventListener('change', function () {
toggleShortlist(tid);
loadMedTargets(container);
});
})(t.id);
li.appendChild(cb);
var nameEl = document.createElement('div');
nameEl.className = 'wtw-list-name';
var link = document.createElement('a');
link.href = 'https://www.torn.com/profiles.php?XID=' + t.id;
link.target = '_blank';
link.textContent = t.name;
nameEl.appendChild(link);
var tag = document.createElement('span');
tag.className = 'wtw-tag wtw-tag-idle';
tag.textContent = formatTime(t.remaining);
nameEl.appendChild(tag);
var detail = document.createElement('div');
detail.className = 'wtw-list-detail';
detail.textContent = 'Level ' + t.level + ' \u2014 ' + t.description;
li.appendChild(nameEl);
li.appendChild(detail);
list.appendChild(li);
}
container.appendChild(list);
}).catch(function (err) {
container.innerHTML = '';
var errEl = document.createElement('div');
errEl.className = 'wtw-status wtw-error';
errEl.textContent = 'Error: ' + err;
container.appendChild(errEl);
});
}
function loadTravelingTargets(container) {
container.innerHTML = '';
var status = document.createElement('div');
status.className = 'wtw-status';
status.textContent = 'Loading...';
container.appendChild(status);
getCachedMembers().then(function (members) {
container.innerHTML = '';
var travel = [];
for (var i = 0; i < members.length; i++) {
var m = members[i];
var p = parseTravelStatus(m);
if (!p) continue;
travel.push({
id: m.id, name: m.name, level: m.level,
type: p.type,
description: (m.status && m.status.description) || ''
});
}
travel.sort(function (a, b) { return b.level - a.level; });
var count = document.createElement('div');
count.className = 'wtw-count';
count.textContent = travel.length + ' member' + (travel.length !== 1 ? 's' : '') + ' traveling or abroad';
container.appendChild(count);
if (travel.length === 0) {
var empty = document.createElement('div');
empty.className = 'wtw-status';
empty.textContent = 'No members traveling';
container.appendChild(empty);
return;
}
var list = document.createElement('ul');
list.className = 'wtw-list';
for (var v = 0; v < travel.length; v++) {
var t = travel[v];
var li = document.createElement('li');
li.className = 'wtw-list-item wtw-list-item-threat-' + t.type;
var isPinned = isShortlisted(t.id);
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'wtw-shortlist-cb';
cb.checked = isPinned;
cb.title = isPinned ? 'Remove from shortlist' : 'Add to shortlist';
(function (tid) {
cb.addEventListener('change', function () {
toggleShortlist(tid);
loadTravelingTargets(container);
});
})(t.id);
li.appendChild(cb);
var nameEl = document.createElement('div');
nameEl.className = 'wtw-list-name';
var link = document.createElement('a');
link.href = 'https://www.torn.com/profiles.php?XID=' + t.id;
link.target = '_blank';
link.textContent = t.name;
nameEl.appendChild(link);
var tag = document.createElement('span');
tag.className = 'wtw-tag wtw-tag-' + t.type;
tag.textContent = t.type === 'traveling' ? 'EN ROUTE' : (t.type === 'returning' ? 'RETURNING' : 'ABROAD');
nameEl.appendChild(tag);
var detail = document.createElement('div');
detail.className = 'wtw-list-detail';
detail.textContent = 'Level ' + t.level + ' \u2014 ' + t.description;
li.appendChild(nameEl);
li.appendChild(detail);
list.appendChild(li);
}
container.appendChild(list);
}).catch(function (err) {
container.innerHTML = '';
var errEl = document.createElement('div');
errEl.className = 'wtw-status wtw-error';
errEl.textContent = 'Error: ' + err;
container.appendChild(errEl);
});
}
// -- Travel Warning Modal ---------------------------------------------
function showTravelWarning(countryName, threats) {
var body = createModal('War Targets in ' + countryName, true);
var sub = document.createElement('div');
sub.className = 'wtw-warn-sub';
sub.textContent = threats.length + ' enemy member' + (threats.length !== 1 ? 's' : '') + ' detected:';
body.appendChild(sub);
var list = document.createElement('ul');
list.className = 'wtw-list';
for (var i = 0; i < threats.length; i++) {
var t = threats[i];
var li = document.createElement('li');
li.className = 'wtw-list-item wtw-list-item-threat-' + t.type;
var nameEl = document.createElement('div');
nameEl.className = 'wtw-list-name';
var link = document.createElement('a');
link.href = 'https://www.torn.com/profiles.php?XID=' + t.id;
link.target = '_blank';
link.textContent = t.name;
nameEl.appendChild(link);
var tag = document.createElement('span');
tag.className = 'wtw-tag wtw-tag-' + t.type;
tag.textContent = t.type === 'traveling' ? 'EN ROUTE' : (t.type === 'returning' ? 'RETURNING' : 'IN COUNTRY');
nameEl.appendChild(tag);
var detail = document.createElement('div');
detail.className = 'wtw-list-detail';
detail.textContent = 'Level ' + t.level + ' \u2014 ' + t.description;
li.appendChild(nameEl);
li.appendChild(detail);
list.appendChild(li);
}
body.appendChild(list);
var dismiss = makeButton('Dismiss', 'wtw-dismiss');
dismiss.addEventListener('click', closeModal);
body.appendChild(dismiss);
}
// -- Sidebar Injection ------------------------------------------------
// Class names are copied from the existing travel nav item at runtime.
// Hashed suffixes change between builds, so lookups use [class*=...] prefixes.
var warSvg = '<svg xmlns="http://www.w3.org/2000/svg" class="wtw-sidebar-icon" stroke="transparent" stroke-width="0" width="16" height="16" viewBox="0 0 16 16"><path d="M8,0L0,3v5c0,4.4,3.4,8.5,8,10c4.6-1.5,8-5.6,8-10V3L8,0z M12,9H9v3H7V9H4V7h3V4h2v3h3V9z"/></svg>';
function copyClass(sourceRoot, selector) {
var el = sourceRoot.querySelector(selector);
return el ? el.className : '';
}
// Users can hide sidebar links (including Travel Agency) in Torn's settings,
// so fall back to the last visible area link as the reference/insertion point.
function findDesktopNavRef() {
var travel = document.querySelector('#nav-travel_agency');
if (travel && !travel.closest('[class*="swiper-slide"]')) return travel;
var candidates = document.querySelectorAll('div[id^="nav-"]');
var last = null;
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
if (el.id.indexOf('nav-war_tools') === 0) continue;
if (el.closest('[class*="swiper-slide"]')) continue;
if (!el.querySelector('[class*="area-row"]')) continue;
last = el;
}
return last;
}
function findMobileNavRef() {
var travel = document.querySelector('#sidebar #nav-travel_agency');
if (travel && travel.closest('[class*="swiper-slide"]')) return travel;
var candidates = document.querySelectorAll('#sidebar div[id^="nav-"]');
var last = null;
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
if (el.id.indexOf('nav-war_tools') === 0) continue;
if (!el.closest('[class*="swiper-slide"]')) continue;
if (!el.querySelector('[class*="area-row"]')) continue;
last = el;
}
return last;
}
function injectDesktopSidebar() {
var refNav = findDesktopNavRef();
if (!refNav) return false;
if (document.querySelector('#nav-war_tools')) return true;
var wrapper = document.createElement('div');
wrapper.className = refNav.className;
wrapper.id = 'nav-war_tools';
var row = document.createElement('div');
row.className = copyClass(refNav, '[class*="area-row"]');
var link = document.createElement('a');
link.href = '#';
link.className = copyClass(refNav, 'a');
link.addEventListener('click', function (e) {
e.preventDefault();
openWarModal();
});
var iconWrap = document.createElement('span');
iconWrap.className = copyClass(refNav, '[class*="svgIconWrap"]');
var iconInner = document.createElement('span');
iconInner.className = copyClass(refNav, '[class*="defaultIcon"]');
iconInner.innerHTML = warSvg;
iconWrap.appendChild(iconInner);
var nameSpan = document.createElement('span');
nameSpan.className = copyClass(refNav, '[class*="linkName"]');
nameSpan.textContent = 'War Tools';
link.appendChild(iconWrap);
link.appendChild(nameSpan);
row.appendChild(link);
wrapper.appendChild(row);
refNav.parentNode.insertBefore(wrapper, refNav.nextSibling);
return true;
}
function injectMobileSidebar() {
var refMobile = findMobileNavRef();
if (!refMobile) return false;
if (document.querySelector('#nav-war_tools_mobile')) return true;
var slide = refMobile.closest('[class*="swiper-slide"]');
if (!slide) return false;
var newSlide = document.createElement('div');
newSlide.className = slide.className;
newSlide.style.cssText = slide.style.cssText;
var wrapper = document.createElement('div');
wrapper.className = refMobile.className;
wrapper.id = 'nav-war_tools_mobile';
var row = document.createElement('div');
row.className = copyClass(refMobile, '[class*="area-row"]');
var link = document.createElement('a');
link.href = '#';
link.tabIndex = 0;
link.className = copyClass(refMobile, 'a');
link.addEventListener('click', function (e) {
e.preventDefault();
openWarModal();
});
var iconWrap = document.createElement('span');
iconWrap.className = copyClass(refMobile, '[class*="svgIconWrap"]');
var iconInner = document.createElement('span');
iconInner.className = copyClass(refMobile, '[class*="defaultIcon"]');
iconInner.innerHTML = warSvg;
iconWrap.appendChild(iconInner);
var nameSpan = document.createElement('span');
nameSpan.textContent = 'War';
link.appendChild(iconWrap);
link.appendChild(nameSpan);
row.appendChild(link);
wrapper.appendChild(row);
newSlide.appendChild(wrapper);
slide.parentNode.insertBefore(newSlide, slide.nextSibling);
return true;
}
function injectSidebar() {
injectDesktopSidebar();
injectMobileSidebar();
}
// -- Travel Page: Destination Click Interception ----------------------
function isTravelPage() {
return window.location.href.indexOf('sid=travel') !== -1 ||
window.location.href.indexOf('travelagency') !== -1;
}
// These use [class^=...] deliberately: Torn's hashed classes sit first in
// the class attribute on these elements, and [class*=...] over-matches here
// (e.g. "pin" hits "spinner", "name" hits unrelated elements), returning a
// wrong country string so no threats matched and no warning showed.
function getCountryFromDesktopButton(el) {
var s = el.querySelector('[class^="country"]');
if (s) return s.textContent.trim();
var n = el.querySelector('[class^="name"]');
if (n) {
var t = n.textContent.trim();
var d = t.indexOf(' -');
return d > 0 ? t.substring(0, d).trim() : t;
}
return null;
}
function getCountryFromMapPin(el) {
var label = el.getAttribute('aria-label');
if (label) {
var d = label.indexOf(' -');
return d > 0 ? label.substring(0, d).trim() : label.trim();
}
var parent = el.closest('[class^="destinationLabel"]');
if (parent) {
var radio = parent.querySelector('input[aria-label]');
if (radio) {
var rl = radio.getAttribute('aria-label');
var di = rl.indexOf(' -');
return di > 0 ? rl.substring(0, di).trim() : rl.trim();
}
}
return null;
}
function extractCountryFromClick(e) {
var target = e.target;
// Desktop list
var desktopBtn = target.closest('button[class^="expandButton"]');
if (desktopBtn) {
var dest = desktopBtn.closest('[class^="destination"]');
return getCountryFromDesktopButton(dest || desktopBtn);
}
// Map label
var mapLabel = target.closest('[class^="destinationLabel"]');
if (mapLabel) {
var radio = mapLabel.querySelector('input[aria-label]');
if (radio) return getCountryFromMapPin(radio);
}
// Map pin
var pin = target.closest('[class^="pin"]');
if (pin) {
var pl = pin.closest('[class^="destinationLabel"]');
if (pl) {
var pr = pl.querySelector('input[aria-label]');
if (pr) return getCountryFromMapPin(pr);
}
}
return null;
}
// Warnings are informational overlays on top of the normal click.
// Blocking the click with preventDefault left Torn's destination UI in a
// broken state after dismiss (the expand/travel button vanished), so the
// click now always passes through and the warning shows over the top.
// Every destination click re-checks; the travel confirm button inside
// the expanded destination doesn't match the extraction selectors, so
// completing a booking isn't interrupted.
function attachTravelListeners() {
document.addEventListener('click', function (e) {
if (!isTravelPage()) return;
if (!isConfigured()) return;
if (e.target.closest('.wtw-overlay')) return;
var country = extractCountryFromClick(e);
if (!country) return;
console.debug('[WTW] Destination click:', country);
// Cache ready: warn immediately
if (memberCache.data && (Date.now() - memberCache.time) < CACHE_TTL) {
var threats = findThreats(memberCache.data, country);
console.debug('[WTW] Threats in', country + ':', threats.length);
if (threats.length === 0) {
var descs = [];
for (var di = 0; di < memberCache.data.length; di++) {
var dm = memberCache.data[di];
var ds = dm.status && dm.status.state;
if (ds === 'Traveling' || ds === 'Abroad') {
descs.push(dm.status.description);
}
}
console.debug('[WTW] No match. Traveling/Abroad statuses:', descs);
}
if (threats.length > 0) {
showTravelWarning(country, threats);
}
return;
}
// No cache: fetch in the background, warn when it returns
getCachedMembers().then(function (members) {
if (!isTravelPage()) return;
var threats = findThreats(members, country);
if (threats.length > 0) {
showTravelWarning(country, threats);
}
}).catch(function (err) {
console.error('[WTW] Fetch error:', err);
});
}, true);
}
// -- Init -------------------------------------------------------------
injectStyles();
function init() {
injectSidebar();
attachTravelListeners();
prefetchMembers();
// Re-inject sidebar if SPA removes it
new MutationObserver(function () {
if (!document.querySelector('#nav-war_tools')) {
injectDesktopSidebar();
}
if (!document.querySelector('#nav-war_tools_mobile')) {
injectMobileSidebar();
}
}).observe(document.body, { childList: true, subtree: true });
// Refresh cache periodically
setInterval(function () {
if (isConfigured()) prefetchMembers();
}, CACHE_TTL);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 300);
}
console.log('[War Tools] v2.15 loaded');
})();