Faction War Intelligence: stats overlay, filters, sorting, and multi-API integration
// ==UserScript==
// @name Torn War Intel
// @namespace https://torn.com/
// @version 1.0.0
// @description Faction War Intelligence: stats overlay, filters, sorting, and multi-API integration
// @author WarIntel
// @match https://www.torn.com/factions.php*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect api.torn.com
// @connect yata.yt
// @connect tornstats.com
// @connect ffscouter.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ─────────────────────────────────────────────
// CONSTANTS & STATE
// ─────────────────────────────────────────────
const STORAGE_KEY = 'TWI_data';
const SETTINGS_KEY = 'TWI_settings';
const VERSION = '1.8.0';
const defaultSettings = {
tornKey: '',
yataKey: '',
tornstatsKey: '',
ffscouterKey: '',
enableYata: true,
enableTornStats: true,
enableFFScouter: true,
};
const defaultFilters = {
minBattle: '',
maxBattle: '',
minStr: '',
maxStr: '',
minSpd: '',
maxSpd: '',
minDef: '',
maxDef: '',
minDex: '',
maxDex: '',
hideUnknown: false,
};
let settings = loadSettings();
let cachedStats = loadCache();
let activeFilters = { ...defaultFilters };
let sortMode = 'default'; // default | status | hospital | abroad | traveling | score
let panelOpen = false;
let settingsOpen = false;
// ─────────────────────────────────────────────
// STORAGE HELPERS
// ─────────────────────────────────────────────
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
return raw ? { ...defaultSettings, ...JSON.parse(raw) } : { ...defaultSettings };
} catch { return { ...defaultSettings }; }
}
function saveSettings() {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function loadCache() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
}
function saveCache() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedStats));
}
function clearCache() {
cachedStats = {};
localStorage.removeItem(STORAGE_KEY);
showToast('Cache cleared!', 'info');
}
// ─────────────────────────────────────────────
// STYLES
// ─────────────────────────────────────────────
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&display=swap');
:root {
--twi-bg: #0a0c10;
--twi-bg2: #0f1318;
--twi-bg3: #151c25;
--twi-border: #1e2d3d;
--twi-accent: #00d4ff;
--twi-accent2: #ff4757;
--twi-accent3: #2ed573;
--twi-gold: #ffa502;
--twi-text: #c8d6e5;
--twi-text-dim: #576574;
--twi-panel-w: 480px;
--twi-font: 'Rajdhani', sans-serif;
--twi-mono: 'Share Tech Mono', monospace;
}
/* ── Floating Toggle Button ── */
#twi-toggle {
position: fixed;
top: 120px;
right: 0;
z-index: 99998;
background: linear-gradient(135deg, #0f1318, #151c25);
border: 1px solid var(--twi-accent);
border-right: none;
border-radius: 8px 0 0 8px;
padding: 10px 14px;
cursor: pointer;
color: var(--twi-accent);
font-family: var(--twi-mono);
font-size: 11px;
letter-spacing: 1px;
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
box-shadow: -4px 0 20px rgba(0,212,255,0.15);
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
#twi-toggle:hover {
background: var(--twi-accent);
color: var(--twi-bg);
box-shadow: -4px 0 30px rgba(0,212,255,0.4);
}
#twi-toggle .twi-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--twi-accent3);
animation: twi-pulse 1.5s infinite;
}
/* ── Main Panel ── */
#twi-panel {
position: fixed;
top: 0;
right: -520px;
width: var(--twi-panel-w);
height: 100vh;
z-index: 99997;
background: var(--twi-bg);
border-left: 1px solid var(--twi-border);
display: flex;
flex-direction: column;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -10px 0 40px rgba(0,0,0,0.6);
font-family: var(--twi-font);
color: var(--twi-text);
overflow: hidden;
}
#twi-panel.open { right: 0; }
/* ── Panel Header ── */
#twi-header {
background: var(--twi-bg2);
border-bottom: 1px solid var(--twi-border);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#twi-header .twi-logo {
font-family: var(--twi-mono);
font-size: 13px;
color: var(--twi-accent);
letter-spacing: 2px;
flex: 1;
}
#twi-header .twi-logo span {
color: var(--twi-accent2);
}
#twi-header-btns {
display: flex;
gap: 6px;
}
.twi-icon-btn {
background: var(--twi-bg3);
border: 1px solid var(--twi-border);
border-radius: 6px;
color: var(--twi-text-dim);
cursor: pointer;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.twi-icon-btn:hover { border-color: var(--twi-accent); color: var(--twi-accent); }
.twi-icon-btn.active { border-color: var(--twi-accent); color: var(--twi-accent); background: rgba(0,212,255,0.1); }
.twi-icon-btn.cooldown {
cursor: not-allowed !important;
opacity: 0.5;
pointer-events: none;
font-size: 10px !important;
font-family: var(--twi-mono);
color: var(--twi-text-dim) !important;
border-color: var(--twi-text-dim) !important;
}
/* ── Tabs ── */
#twi-tabs {
display: flex;
border-bottom: 1px solid var(--twi-border);
background: var(--twi-bg2);
flex-shrink: 0;
}
.twi-tab {
flex: 1;
padding: 9px 0;
text-align: center;
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--twi-text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.twi-tab:hover { color: var(--twi-text); }
.twi-tab.active { color: var(--twi-accent); border-bottom-color: var(--twi-accent); }
/* ── Content Area ── */
#twi-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
#twi-content::-webkit-scrollbar { width: 4px; }
#twi-content::-webkit-scrollbar-track { background: var(--twi-bg); }
#twi-content::-webkit-scrollbar-thumb { background: var(--twi-border); border-radius: 2px; }
/* ── Tab Panels ── */
.twi-tab-panel { display: none; padding: 12px; }
.twi-tab-panel.active { display: block; }
/* ── Section Header ── */
.twi-section-title {
font-family: var(--twi-mono);
font-size: 10px;
letter-spacing: 2px;
color: var(--twi-text-dim);
text-transform: uppercase;
margin: 12px 0 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--twi-border);
}
/* ── Sort Sticky Container ── */
#twi-sort-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--twi-bg);
padding: 8px 0 4px;
border-bottom: 1px solid var(--twi-border);
margin-bottom: 8px;
}
/* ── Sort Bar ── */
#twi-sort-bar {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.twi-sort-btn {
background: var(--twi-bg3);
border: 1px solid var(--twi-border);
border-radius: 5px;
color: var(--twi-text-dim);
font-family: var(--twi-font);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s;
}
.twi-sort-btn:hover { border-color: var(--twi-gold); color: var(--twi-gold); }
.twi-sort-btn.active { background: rgba(255,165,2,0.15); border-color: var(--twi-gold); color: var(--twi-gold); }
/* ── Filter Grid ── */
.twi-filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 10px;
}
.twi-filter-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.twi-filter-group label {
font-size: 10px;
font-family: var(--twi-mono);
color: var(--twi-text-dim);
letter-spacing: 1px;
}
.twi-filter-group input[type="number"] {
background: var(--twi-bg3);
border: 1px solid var(--twi-border);
border-radius: 5px;
color: var(--twi-text);
font-family: var(--twi-mono);
font-size: 12px;
padding: 5px 8px;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.twi-filter-group input[type="number"]:focus {
outline: none;
border-color: var(--twi-accent);
}
.twi-filter-group input[type="number"]::placeholder { color: var(--twi-text-dim); }
.twi-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
cursor: pointer;
}
.twi-checkbox-row input[type="checkbox"] { accent-color: var(--twi-accent); cursor: pointer; }
.twi-checkbox-row span { font-size: 12px; font-weight: 600; }
.twi-btn {
background: transparent;
border: 1px solid var(--twi-accent);
border-radius: 6px;
color: var(--twi-accent);
font-family: var(--twi-font);
font-weight: 700;
font-size: 12px;
letter-spacing: 1px;
padding: 7px 14px;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
}
.twi-btn:hover { background: rgba(0,212,255,0.15); }
.twi-btn.danger { border-color: var(--twi-accent2); color: var(--twi-accent2); }
.twi-btn.danger:hover { background: rgba(255,71,87,0.15); }
.twi-btn.success { border-color: var(--twi-accent3); color: var(--twi-accent3); }
.twi-btn.success:hover { background: rgba(46,213,115,0.15); }
.twi-btn-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
/* ── Member Cards ── */
.twi-member-card {
background: var(--twi-bg2);
border: 1px solid var(--twi-border);
border-radius: 8px;
margin-bottom: 6px;
overflow: hidden;
transition: border-color 0.2s;
}
.twi-member-card:hover { border-color: rgba(0,212,255,0.3); }
.twi-member-card.hidden { display: none; }
.twi-member-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
user-select: none;
}
.twi-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.twi-status-dot.okay { background: var(--twi-accent3); box-shadow: 0 0 6px var(--twi-accent3); }
.twi-status-dot.hospital { background: var(--twi-accent2); box-shadow: 0 0 6px var(--twi-accent2); }
.twi-status-dot.abroad, .twi-status-dot.traveling { background: var(--twi-gold); box-shadow: 0 0 6px var(--twi-gold); }
.twi-status-dot.jail { background: #a855f7; box-shadow: 0 0 6px #a855f7; }
.twi-status-dot.unknown { background: var(--twi-text-dim); }
/* ── Online indicator ── */
.twi-online-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
position: relative;
}
.twi-online-dot.online {
background: #2ed573;
box-shadow: 0 0 5px #2ed573;
animation: twi-online-pulse 2s infinite;
}
.twi-online-dot.idle {
background: var(--twi-gold);
box-shadow: 0 0 5px var(--twi-gold);
}
.twi-online-dot.offline { background: #444c56; }
@keyframes twi-online-pulse {
0%, 100% { box-shadow: 0 0 4px #2ed573; }
50% { box-shadow: 0 0 10px #2ed573, 0 0 20px rgba(46,213,115,0.4); }
}
/* ── Live DOM badge ── */
.twi-live-badge {
font-family: var(--twi-mono);
font-size: 9px;
letter-spacing: 1px;
color: var(--twi-accent3);
background: rgba(46,213,115,0.1);
border: 1px solid rgba(46,213,115,0.3);
border-radius: 3px;
padding: 1px 5px;
}
.twi-member-name {
flex: 1;
font-size: 15px;
font-weight: 700;
color: var(--twi-text);
text-decoration: none;
cursor: default;
}
.twi-member-level {
font-family: var(--twi-mono);
font-size: 12px;
color: var(--twi-text-dim);
padding: 2px 6px;
background: var(--twi-bg3);
border-radius: 4px;
flex-shrink: 0;
}
.twi-member-score {
font-family: var(--twi-mono);
font-size: 12px;
color: var(--twi-gold);
flex-shrink: 0;
}
.twi-battle-inline {
font-family: var(--twi-mono);
font-size: 13px;
color: var(--twi-accent);
background: rgba(0,212,255,0.08);
border: 1px solid rgba(0,212,255,0.2);
border-radius: 4px;
padding: 2px 6px;
flex-shrink: 0;
}
.twi-battle-inline.unknown {
color: var(--twi-text-dim);
background: transparent;
border-color: var(--twi-border);
}
.twi-action-btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.twi-action-btn {
font-family: var(--twi-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 3px 7px;
border-radius: 4px;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
.twi-action-btn.profile {
border-color: var(--twi-border);
color: var(--twi-text-dim);
background: transparent;
}
.twi-action-btn.profile:hover {
border-color: var(--twi-accent);
color: var(--twi-accent);
}
.twi-action-btn.attack {
border-color: var(--twi-accent2);
color: var(--twi-accent2);
background: rgba(255,71,87,0.08);
}
.twi-action-btn.attack:hover {
background: rgba(255,71,87,0.22);
}
.twi-status-badge {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 2px 7px;
border-radius: 4px;
}
.twi-status-badge.okay { background: rgba(46,213,115,0.15); color: var(--twi-accent3); }
.twi-status-badge.hospital { background: rgba(255,71,87,0.15); color: var(--twi-accent2); }
.twi-status-badge.abroad { background: rgba(255,165,2,0.15); color: var(--twi-gold); }
.twi-status-badge.traveling { background: rgba(255,165,2,0.15); color: var(--twi-gold); }
.twi-expand-icon {
font-size: 10px;
color: var(--twi-text-dim);
transition: transform 0.2s;
}
.twi-member-card.expanded .twi-expand-icon { transform: rotate(180deg); }
/* ── Stats Body ── */
.twi-stats-body {
display: none;
padding: 0 10px 10px;
border-top: 1px solid var(--twi-border);
}
.twi-member-card.expanded .twi-stats-body { display: block; }
.twi-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
margin-top: 8px;
}
.twi-stat-box {
background: var(--twi-bg3);
border: 1px solid var(--twi-border);
border-radius: 6px;
padding: 6px 8px;
text-align: center;
}
.twi-stat-label {
font-family: var(--twi-mono);
font-size: 11px;
letter-spacing: 1px;
color: var(--twi-text-dim);
text-transform: uppercase;
margin-bottom: 3px;
}
.twi-stat-value {
font-family: var(--twi-mono);
font-size: 15px;
font-weight: bold;
color: var(--twi-accent);
}
.twi-stat-value.unknown { color: var(--twi-text-dim); font-size: 13px; }
.twi-stat-source {
font-size: 9px;
font-family: var(--twi-mono);
color: var(--twi-text-dim);
margin-top: 6px;
text-align: right;
letter-spacing: 0.5px;
}
.twi-loading-bar {
height: 2px;
background: linear-gradient(90deg, var(--twi-accent), var(--twi-accent2));
border-radius: 1px;
margin: 6px 0;
animation: twi-shimmer 1.5s infinite;
}
@keyframes twi-shimmer {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
/* ── Settings Panel ── */
#twi-settings-overlay {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.7);
z-index: 100000;
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
}
#twi-settings-overlay.open { display: flex; }
#twi-settings-box {
background: var(--twi-bg);
border: 1px solid var(--twi-border);
border-radius: 12px;
width: 440px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.8);
font-family: var(--twi-font);
color: var(--twi-text);
}
#twi-settings-box::-webkit-scrollbar { width: 4px; }
#twi-settings-box::-webkit-scrollbar-thumb { background: var(--twi-border); border-radius: 2px; }
.twi-settings-header {
background: var(--twi-bg2);
border-bottom: 1px solid var(--twi-border);
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 12px 12px 0 0;
position: sticky;
top: 0;
z-index: 1;
}
.twi-settings-header h2 {
margin: 0;
font-family: var(--twi-mono);
font-size: 14px;
letter-spacing: 2px;
color: var(--twi-accent);
}
.twi-settings-body { padding: 16px 20px; }
.twi-api-card {
background: var(--twi-bg2);
border: 1px solid var(--twi-border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
transition: border-color 0.2s;
}
.twi-api-card:focus-within { border-color: var(--twi-accent); }
.twi-api-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.twi-api-card-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.twi-api-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--twi-text-dim);
}
.twi-api-dot.configured { background: var(--twi-accent3); box-shadow: 0 0 6px var(--twi-accent3); }
.twi-toggle-switch {
position: relative;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.twi-toggle-switch input { display: none; }
.twi-toggle-switch .slider {
position: absolute;
inset: 0;
background: var(--twi-bg3);
border: 1px solid var(--twi-border);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.twi-toggle-switch .slider:before {
content: '';
position: absolute;
width: 14px;
height: 14px;
left: 2px;
top: 2px;
background: var(--twi-text-dim);
border-radius: 50%;
transition: all 0.2s;
}
.twi-toggle-switch input:checked + .slider { background: rgba(0,212,255,0.2); border-color: var(--twi-accent); }
.twi-toggle-switch input:checked + .slider:before { transform: translateX(16px); background: var(--twi-accent); }
.twi-input-field {
width: 100%;
box-sizing: border-box;
background: var(--twi-bg3);
border: 1px solid var(--twi-border);
border-radius: 6px;
color: var(--twi-text);
font-family: var(--twi-mono);
font-size: 12px;
padding: 7px 10px;
transition: border-color 0.2s;
}
.twi-input-field:focus { outline: none; border-color: var(--twi-accent); }
.twi-input-field::placeholder { color: var(--twi-text-dim); }
.twi-input-label {
font-size: 10px;
font-family: var(--twi-mono);
color: var(--twi-text-dim);
letter-spacing: 1px;
margin-bottom: 4px;
display: block;
}
/* ── Status Summary Bar ── */
#twi-summary {
display: flex;
gap: 6px;
padding: 8px 12px;
background: var(--twi-bg2);
border-bottom: 1px solid var(--twi-border);
flex-shrink: 0;
}
.twi-summary-pill {
flex: 1;
text-align: center;
padding: 4px 6px;
border-radius: 5px;
font-family: var(--twi-mono);
font-size: 11px;
}
.twi-summary-pill .count { font-size: 16px; font-weight: bold; display: block; line-height: 1.2; }
.twi-summary-pill.okay { background: rgba(46,213,115,0.1); color: var(--twi-accent3); border: 1px solid rgba(46,213,115,0.3); }
.twi-summary-pill.hospital { background: rgba(255,71,87,0.1); color: var(--twi-accent2); border: 1px solid rgba(255,71,87,0.3); }
.twi-summary-pill.away { background: rgba(255,165,2,0.1); color: var(--twi-gold); border: 1px solid rgba(255,165,2,0.3); }
.twi-summary-pill.unknown { background: rgba(87,101,116,0.1); color: var(--twi-text-dim); border: 1px solid rgba(87,101,116,0.3); }
/* ── Toast ── */
#twi-toast {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 100001;
display: flex;
flex-direction: column;
gap: 8px;
}
.twi-toast-item {
background: var(--twi-bg2);
border: 1px solid var(--twi-border);
border-radius: 8px;
padding: 10px 16px;
font-family: var(--twi-font);
font-size: 13px;
color: var(--twi-text);
animation: twi-toast-in 0.3s ease;
min-width: 200px;
display: flex;
align-items: center;
gap: 8px;
}
.twi-toast-item.success { border-color: var(--twi-accent3); }
.twi-toast-item.error { border-color: var(--twi-accent2); }
.twi-toast-item.info { border-color: var(--twi-accent); }
@keyframes twi-toast-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes twi-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
/* ── Empty State ── */
.twi-empty {
text-align: center;
padding: 30px 20px;
color: var(--twi-text-dim);
font-family: var(--twi-mono);
font-size: 12px;
letter-spacing: 1px;
}
.twi-empty .twi-empty-icon { font-size: 32px; margin-bottom: 8px; }
/* ── Refresh indicator ── */
.twi-refreshing { animation: twi-spin 1s linear infinite; display: inline-block; }
@keyframes twi-spin { to { transform: rotate(360deg); } }
`;
document.head.appendChild(style);
}
// ─────────────────────────────────────────────
// UI BUILDER
// ─────────────────────────────────────────────
function buildUI() {
// Toast container
const toast = document.createElement('div');
toast.id = 'twi-toast';
document.body.appendChild(toast);
// Toggle button
const toggle = document.createElement('div');
toggle.id = 'twi-toggle';
toggle.innerHTML = `<div class="twi-pulse"></div>WAR INTEL`;
toggle.title = 'Toggle War Intel Panel';
toggle.addEventListener('click', togglePanel);
document.body.appendChild(toggle);
// Main panel
const panel = document.createElement('div');
panel.id = 'twi-panel';
panel.innerHTML = `
<div id="twi-header">
<div class="twi-logo">WAR<span>INTEL</span> <span style="font-size:9px;color:var(--twi-text-dim)">v${VERSION} by Pentax</span> <span class="twi-live-badge">● LIVE</span></div>
<div id="twi-header-btns">
<button class="twi-icon-btn" id="twi-refresh-members-btn" title="Refresh Members">↻</button>
<button class="twi-icon-btn" id="twi-refresh-stats-btn" title="Refresh Stats from APIs" style="font-size:11px;">📊</button>
<button class="twi-icon-btn" id="twi-settings-btn" title="Settings">⚙</button>
<button class="twi-icon-btn" id="twi-close-btn" title="Close">✕</button>
</div>
</div>
<div id="twi-tabs">
<div class="twi-tab active" data-tab="members">Members</div>
<div class="twi-tab" data-tab="filters">Filters</div>
<div class="twi-tab" data-tab="cache">Cache</div>
</div>
<div id="twi-summary">
<div class="twi-summary-pill okay"><span class="count" id="twi-count-okay">0</span>Okay</div>
<div class="twi-summary-pill hospital"><span class="count" id="twi-count-hosp">0</span>Hospital</div>
<div class="twi-summary-pill away"><span class="count" id="twi-count-away">0</span>Away</div>
<div class="twi-summary-pill unknown"><span class="count" id="twi-count-unk">0</span>Unknown</div>
</div>
<div id="twi-content">
<!-- Members Tab -->
<div class="twi-tab-panel active" id="tab-members">
<div id="twi-sort-sticky">
<div class="twi-section-title" style="margin-bottom:6px;">Sort by</div>
<div id="twi-sort-bar">
<button class="twi-sort-btn active" data-sort="default">Default</button>
<button class="twi-sort-btn" data-sort="okay">🟢 Okay</button>
<button class="twi-sort-btn" data-sort="hospital">🔴 Hospital</button>
<button class="twi-sort-btn" data-sort="abroad">🟡 Abroad</button>
<button class="twi-sort-btn" data-sort="traveling">✈ Traveling</button>
<button class="twi-sort-btn" data-sort="score">Score ↓</button>
<button class="twi-sort-btn" data-sort="battle">Battle ↓</button>
</div>
<div class="twi-section-title" style="margin-top:8px;margin-bottom:4px;">Enemy Members</div>
</div>
<div id="twi-member-list">
<div class="twi-empty"><div class="twi-empty-icon">⚔</div>Navigate to a faction war page to load members</div>
</div>
</div>
<!-- Filters Tab -->
<div class="twi-tab-panel" id="tab-filters">
<div class="twi-section-title">Battle Stats Range</div>
<div class="twi-filter-grid">
<div class="twi-filter-group">
<label>MIN BATTLE</label>
<input type="text" inputmode="numeric" id="f-min-battle" placeholder="e.g. 1,000,000">
</div>
<div class="twi-filter-group">
<label>MAX BATTLE</label>
<input type="text" inputmode="numeric" id="f-max-battle" placeholder="e.g. 50,000,000">
</div>
</div>
<div class="twi-section-title">Individual Stats</div>
<div class="twi-filter-grid">
<div class="twi-filter-group">
<label>MIN STR</label>
<input type="text" inputmode="numeric" id="f-min-str" placeholder="e.g. 500,000">
</div>
<div class="twi-filter-group">
<label>MAX STR</label>
<input type="text" inputmode="numeric" id="f-max-str" placeholder="e.g. 10,000,000">
</div>
<div class="twi-filter-group">
<label>MIN SPD</label>
<input type="text" inputmode="numeric" id="f-min-spd" placeholder="e.g. 500,000">
</div>
<div class="twi-filter-group">
<label>MAX SPD</label>
<input type="text" inputmode="numeric" id="f-max-spd" placeholder="e.g. 10,000,000">
</div>
<div class="twi-filter-group">
<label>MIN DEF</label>
<input type="text" inputmode="numeric" id="f-min-def" placeholder="e.g. 500,000">
</div>
<div class="twi-filter-group">
<label>MAX DEF</label>
<input type="text" inputmode="numeric" id="f-max-def" placeholder="e.g. 10,000,000">
</div>
<div class="twi-filter-group">
<label>MIN DEX</label>
<input type="text" inputmode="numeric" id="f-min-dex" placeholder="e.g. 500,000">
</div>
<div class="twi-filter-group">
<label>MAX DEX</label>
<input type="text" inputmode="numeric" id="f-max-dex" placeholder="e.g. 10,000,000">
</div>
</div>
<label class="twi-checkbox-row">
<input type="checkbox" id="f-hide-unknown">
<span>Hide members with unknown stats</span>
</label>
<div class="twi-btn-row">
<button class="twi-btn" id="apply-filters-btn">Apply Filters</button>
<button class="twi-btn danger" id="reset-filters-btn">Reset</button>
</div>
</div>
<!-- Cache Tab -->
<div class="twi-tab-panel" id="tab-cache">
<div class="twi-section-title">Cache Management</div>
<div id="twi-cache-info" style="font-family:var(--twi-mono);font-size:12px;color:var(--twi-text-dim);margin-bottom:10px;line-height:1.8;"></div>
<div class="twi-btn-row">
<button class="twi-btn success" id="force-refresh-btn">Force Refresh All</button>
<button class="twi-btn danger" id="clear-cache-btn">Clear Cache</button>
</div>
<div class="twi-section-title" style="margin-top:16px;">Cached Entries</div>
<div id="twi-cache-list" style="font-family:var(--twi-mono);font-size:11px;color:var(--twi-text-dim);"></div>
</div>
</div>
`;
document.body.appendChild(panel);
// Settings overlay
const settingsOverlay = document.createElement('div');
settingsOverlay.id = 'twi-settings-overlay';
settingsOverlay.innerHTML = `
<div id="twi-settings-box">
<div class="twi-settings-header">
<h2>⚙ SETTINGS</h2>
<button class="twi-icon-btn" id="close-settings-btn">✕</button>
</div>
<div class="twi-settings-body">
<div class="twi-section-title">API Keys</div>
<div class="twi-api-card" id="card-torn">
<div class="twi-api-card-header">
<div class="twi-api-card-title">
<div class="twi-api-dot" id="dot-torn"></div>
Torn API
</div>
</div>
<label class="twi-input-label">API KEY (Full or Limited)</label>
<input class="twi-input-field" type="password" id="input-torn" placeholder="16-character key…">
</div>
<div class="twi-api-card" id="card-yata">
<div class="twi-api-card-header">
<div class="twi-api-card-title">
<div class="twi-api-dot" id="dot-yata"></div>
YATA
</div>
<label class="twi-toggle-switch">
<input type="checkbox" id="toggle-yata">
<div class="slider"></div>
</label>
</div>
<label class="twi-input-label">YATA API KEY</label>
<input class="twi-input-field" type="password" id="input-yata" placeholder="YATA key…">
</div>
<div class="twi-api-card" id="card-tornstats">
<div class="twi-api-card-header">
<div class="twi-api-card-title">
<div class="twi-api-dot" id="dot-tornstats"></div>
TornStats
</div>
<label class="twi-toggle-switch">
<input type="checkbox" id="toggle-tornstats">
<div class="slider"></div>
</label>
</div>
<label class="twi-input-label">TORNSTATS API KEY</label>
<input class="twi-input-field" type="password" id="input-tornstats" placeholder="TornStats key…">
</div>
<div class="twi-api-card" id="card-ffscouter">
<div class="twi-api-card-header">
<div class="twi-api-card-title">
<div class="twi-api-dot" id="dot-ffscouter"></div>
FFScouter
</div>
<label class="twi-toggle-switch">
<input type="checkbox" id="toggle-ffscouter">
<div class="slider"></div>
</label>
</div>
<label class="twi-input-label">FFSCOUTER API KEY</label>
<input class="twi-input-field" type="password" id="input-ffscouter" placeholder="FFScouter key…">
</div>
<div class="twi-section-title">API ToS Disclosure</div>
<div style="font-size:11px;font-family:var(--twi-mono);color:var(--twi-text-dim);line-height:1.7;margin-bottom:14px;">
Data Storage: Only locally (localStorage)<br>
Data Sharing: Nobody<br>
Purpose: Personal war intelligence tool<br>
Key Storage: Stored locally, never shared<br>
Key Access: Minimal / Limited
</div>
<div class="twi-btn-row">
<button class="twi-btn success" id="save-settings-btn">Save Settings</button>
<button class="twi-btn" id="cancel-settings-btn">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(settingsOverlay);
bindEvents();
populateSettingsForm();
bindFilterFormatting();
}
// ─────────────────────────────────────────────
// EVENT BINDING
// ─────────────────────────────────────────────
function bindEvents() {
// Panel controls
document.getElementById('twi-close-btn').addEventListener('click', togglePanel);
document.getElementById('twi-settings-btn').addEventListener('click', openSettings);
document.getElementById('twi-refresh-members-btn').addEventListener('click', () => {
if (isOnCooldown('members')) return;
startCooldown('members', 'twi-refresh-members-btn');
reloadMembers();
});
document.getElementById('twi-refresh-stats-btn').addEventListener('click', () => {
if (isOnCooldown('stats')) return;
startCooldown('stats', 'twi-refresh-stats-btn');
refreshStats(true);
});
// Tabs
document.querySelectorAll('.twi-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.twi-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.twi-tab-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'cache') renderCacheTab();
});
});
// Sort buttons
document.querySelectorAll('.twi-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.twi-sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
sortMode = btn.dataset.sort;
renderMemberList();
});
});
// Filter controls
document.getElementById('apply-filters-btn').addEventListener('click', applyFilters);
document.getElementById('reset-filters-btn').addEventListener('click', resetFilters);
// Cache controls
document.getElementById('force-refresh-btn').addEventListener('click', () => reloadMembers());
document.getElementById('clear-cache-btn').addEventListener('click', () => {
clearCache();
renderCacheTab();
});
// Settings controls
document.getElementById('close-settings-btn').addEventListener('click', closeSettings);
document.getElementById('cancel-settings-btn').addEventListener('click', closeSettings);
document.getElementById('save-settings-btn').addEventListener('click', saveSettingsForm);
// Close overlay on backdrop click
document.getElementById('twi-settings-overlay').addEventListener('click', (e) => {
if (e.target === document.getElementById('twi-settings-overlay')) closeSettings();
});
}
// ─────────────────────────────────────────────
// PANEL TOGGLE
// ─────────────────────────────────────────────
async function togglePanel() {
panelOpen = !panelOpen;
document.getElementById('twi-panel').classList.toggle('open', panelOpen);
if (panelOpen) {
await loadMembers();
} else {
stopLiveObserver();
stopHospitalCountdowns();
}
}
// ── Refresh Members: re-fetch member status from Torn API, preserve cached stats ──
async function reloadMembers() {
stopLiveObserver();
const container = document.getElementById('twi-member-list');
if (container) {
container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon" style="animation:twi-spin 1s linear infinite;display:inline-block">↻</div><br>Refreshing members…</div>`;
}
const freshMembers = await parseEnemyMembers();
if (freshMembers.length === 0) {
showToast('Could not fetch members — check API key and war page', 'error');
if (container) container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">⚔</div>Refresh failed. Check Settings ⚙</div>`;
return;
}
// Merge fresh status fields onto existing members — preserve cached battle stats
const freshMap = Object.fromEntries(freshMembers.map(m => [m.xid, m]));
if (currentMembers.length > 0) {
currentMembers.forEach(m => {
const fresh = freshMap[m.xid];
if (!fresh) return;
m.status = fresh.status;
m.apiOnline = fresh.apiOnline;
m.lastAction = fresh.lastAction;
m.level = fresh.level;
});
// Add brand-new members who joined mid-war
freshMembers.forEach(fm => {
if (!currentMembers.find(m => m.xid === fm.xid)) currentMembers.push(fm);
});
} else {
currentMembers = freshMembers;
}
// Reset memberState so live observer picks up fresh status transitions
for (const xid of Object.keys(memberState)) {
memberState[xid].domStatus = null; // force re-detect on next DOM scrape
}
renderMemberList();
startLiveObserver();
showToast(`Members refreshed — ${freshMembers.length} members`, 'success');
}
async function loadMembers() {
const container = document.getElementById('twi-member-list');
if (container) {
container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon" style="animation:twi-spin 1s linear infinite;display:inline-block">↻</div><br>Loading members…</div>`;
}
const members = await parseEnemyMembers();
if (members.length > 0) {
renderMemberList(members);
refreshStats(false);
// Start live observer — syncs DOM status + handles hospital transitions
startLiveObserver();
} else {
if (container) {
container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">⚔</div>No enemy members found.<br><br><b style="color:var(--twi-accent)">Tips:</b><br>• Add your Torn API key in Settings ⚙<br>• Make sure you're on the #/war/rank tab<br>• Try the Refresh ↻ button<br><br><span style="font-size:10px;color:var(--twi-text-dim)">v${VERSION}</span></div>`;
}
}
}
// ─────────────────────────────────────────────
// SETTINGS
// ─────────────────────────────────────────────
function openSettings() {
populateSettingsForm();
document.getElementById('twi-settings-overlay').classList.add('open');
}
function closeSettings() {
document.getElementById('twi-settings-overlay').classList.remove('open');
}
function populateSettingsForm() {
document.getElementById('input-torn').value = settings.tornKey || '';
document.getElementById('input-yata').value = settings.yataKey || '';
document.getElementById('input-tornstats').value = settings.tornstatsKey || '';
document.getElementById('input-ffscouter').value = settings.ffscouterKey || '';
document.getElementById('toggle-yata').checked = settings.enableYata;
document.getElementById('toggle-tornstats').checked = settings.enableTornStats;
document.getElementById('toggle-ffscouter').checked = settings.enableFFScouter;
updateApiDots();
}
function updateApiDots() {
const apis = { torn: settings.tornKey, yata: settings.yataKey, tornstats: settings.tornstatsKey, ffscouter: settings.ffscouterKey };
for (const [name, key] of Object.entries(apis)) {
const dot = document.getElementById('dot-' + name);
if (dot) dot.classList.toggle('configured', !!key);
}
}
function saveSettingsForm() {
settings.tornKey = document.getElementById('input-torn').value.trim();
settings.yataKey = document.getElementById('input-yata').value.trim();
settings.tornstatsKey = document.getElementById('input-tornstats').value.trim();
settings.ffscouterKey = document.getElementById('input-ffscouter').value.trim();
settings.enableYata = document.getElementById('toggle-yata').checked;
settings.enableTornStats = document.getElementById('toggle-tornstats').checked;
settings.enableFFScouter = document.getElementById('toggle-ffscouter').checked;
saveSettings();
updateApiDots();
closeSettings();
showToast('Settings saved!', 'success');
}
// ─────────────────────────────────────────────
// DOM PARSER - Extract enemy members from page
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// LIVE DOM STATUS SCRAPER
// Reads status, hospital timer, and online state directly
// from Torn's rendered React DOM — always real-time, no API lag
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// LIVE STATUS SYSTEM
//
// Flow:
// 1. DOM MutationObserver watches li[class*="enemy"] rows on the Torn page
// 2. On every DOM change it scrapes: online status + member status (okay/hospital/etc.)
// 3. If DOM says "Hospital" → show hospital badge + start API countdown timer
// 4. If DOM says "Okay" → show okay badge, hide timer
// 5. When API timer hits 0 → trust DOM to flip it back to Okay naturally
//
// Online dot (online/idle/offline) is ALWAYS from DOM — API can't provide this reliably.
// Hospital timer countdown uses API's status.until Unix timestamp (already fetched).
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// BUTTON COOLDOWN SYSTEM
// Torn ToS hard cap: 100 req/min (can drop to 50 without notice).
// ↻ Members: 60s cooldown — 3 Torn API calls (user, rankedwars, faction members)
// 📊 Stats: 30s cooldown — 3rd-party APIs only (TornStats/YATA/FFS)
// May call Torn API if member list is empty.
// Buttons show a live countdown and cannot be clicked during cooldown.
// ─────────────────────────────────────────────
const BTN_COOLDOWNS = { members: 60, stats: 30 };
const _cdTimers = {};
const _cdExpiry = {};
function startCooldown(key, btnId) {
const btn = document.getElementById(btnId);
if (!btn) return;
if (_cdTimers[key]) clearInterval(_cdTimers[key]);
_cdExpiry[key] = Date.now() + BTN_COOLDOWNS[key] * 1000;
btn.dataset.origContent = btn.innerHTML;
btn.dataset.origTitle = btn.title;
btn.classList.add('cooldown');
function tick() {
const rem = Math.ceil((_cdExpiry[key] - Date.now()) / 1000);
if (rem <= 0) {
clearInterval(_cdTimers[key]);
_cdTimers[key] = null;
btn.classList.remove('cooldown');
btn.innerHTML = btn.dataset.origContent;
btn.title = btn.dataset.origTitle;
return;
}
btn.innerHTML = `${rem}s`;
btn.title = `Available in ${rem}s`;
}
tick();
_cdTimers[key] = setInterval(tick, 1000);
}
function isOnCooldown(key) {
return !!(_cdExpiry[key] && Date.now() < _cdExpiry[key]);
}
// liveStatusMap[xid] = { online, domStatus }
// ─────────────────────────────────────────────────────────────────
// LIVE STATUS SYSTEM
//
// DOM-only. Zero API calls during monitoring.
// MutationObserver watches Torn's React war table and reads:
// - Member status (okay/hospital/traveling/abroad/jail) from the status cell
// - Online/idle/offline from aria-label on the user status wrap
// Fully ToS compliant — no API calls whatsoever during live monitoring.
// ─────────────────────────────────────────────────────────────────
// Per-member state: xid → { domStatus, online }
const memberState = {};
// ── 1. DOM SCRAPER — reads Torn's rendered enemy rows ──
function scrapeEnemyRows() {
const result = {};
document.querySelectorAll('li[class*="enemy"]').forEach(li => {
const link = li.querySelector('a[href*="profiles.php?XID="]');
if (!link) return;
const xid = link.href.match(/XID=(\d+)/)?.[1];
if (!xid) return;
// Online status from aria-label on the user status wrap
let online = 'offline';
const wrap = li.querySelector('[class*="userStatusWrap"]');
if (wrap) {
const lbl = (wrap.getAttribute('aria-label') || '').toLowerCase();
if (lbl.includes('is online')) online = 'online';
else if (lbl.includes('is idle')) online = 'idle';
}
// Game status from the status cell
let domStatus = 'unknown';
const cell = li.querySelector('[class*="status___"]') || li.querySelector('.status');
if (cell) {
const txt = cell.textContent.trim().toLowerCase();
if (txt.includes('okay')) domStatus = 'okay';
else if (txt.includes('hospital')) domStatus = 'hospital';
else if (txt.includes('traveling')) domStatus = 'traveling';
else if (txt.includes('abroad')) domStatus = 'abroad';
else if (txt.includes('jail') || txt.includes('federal')) domStatus = 'jail';
}
result[xid] = { online, domStatus };
});
return result;
}
// ── 2. APPLY DOM STATUS UPDATE to a member's card (lightweight, no re-render) ──
function applyStatusToCard(xid, domStatus, online) {
const card = document.querySelector(`.twi-member-card[data-xid="${xid}"]`);
if (!card) return;
card.dataset.status = domStatus;
const dot = card.querySelector('.twi-status-dot');
if (dot) dot.className = `twi-status-dot ${domStatus}`;
const badge = card.querySelector('.twi-status-badge');
if (badge) {
badge.className = `twi-status-badge ${domStatus}`;
badge.textContent = capitalize(domStatus);
}
const onlineDot = card.querySelector('.twi-online-dot');
if (onlineDot) {
onlineDot.className = `twi-online-dot ${online}`;
onlineDot.title = capitalize(online);
}
}
// ── 3. MAIN SYNC — called by MutationObserver on every DOM change ──
function syncFromDOM() {
const scraped = scrapeEnemyRows();
if (Object.keys(scraped).length === 0) return;
let countsChanged = false;
for (const [xid, row] of Object.entries(scraped)) {
if (!memberState[xid]) {
memberState[xid] = { domStatus: null, online: 'offline' };
}
const ms = memberState[xid];
const prevStatus = ms.domStatus;
// Update online dot immediately if changed
if (ms.online !== row.online) {
ms.online = row.online;
const card = document.querySelector(`.twi-member-card[data-xid="${xid}"]`);
if (card) {
const dot = card.querySelector('.twi-online-dot');
if (dot) {
dot.className = `twi-online-dot ${row.online}`;
dot.title = capitalize(row.online);
}
}
}
if (prevStatus === row.domStatus) continue; // no status change
ms.domStatus = row.domStatus;
applyStatusToCard(xid, row.domStatus, row.online);
countsChanged = true;
}
if (countsChanged) refreshSummaryCounts();
}
// ── 4. SUMMARY BAR refresh ──
// ── 7. SUMMARY BAR refresh ──
function refreshSummaryCounts() {
const counts = { okay: 0, hospital: 0, away: 0, unknown: 0 };
currentMembers.forEach(m => {
const st = memberState[m.xid]?.domStatus || m.status;
if (st === 'okay') counts.okay++;
else if (st === 'hospital') counts.hospital++;
else if (st === 'traveling' || st === 'abroad') counts.away++;
else counts.unknown++;
});
const $ = id => document.getElementById(id);
if ($('twi-count-okay')) $('twi-count-okay').textContent = counts.okay;
if ($('twi-count-hosp')) $('twi-count-hosp').textContent = counts.hospital;
if ($('twi-count-away')) $('twi-count-away').textContent = counts.away;
if ($('twi-count-unk')) $('twi-count-unk').textContent = counts.unknown;
applyFiltersToDOM();
}
// ── 9. MUTATIONOBSERVER — watches Torn's React war table ──
let liveObserver = null;
function startLiveObserver() {
if (liveObserver) liveObserver.disconnect();
// Throttle: don't fire more than once per 500ms to avoid excess work
let throttleTimer = null;
function onMutation() {
if (throttleTimer) return;
throttleTimer = setTimeout(() => { throttleTimer = null; syncFromDOM(); }, 500);
}
syncFromDOM(); // initial sync
liveObserver = new MutationObserver(onMutation);
const root = document.getElementById('react-root') || document.body;
liveObserver.observe(root, { childList: true, subtree: true, characterData: false });
}
function stopLiveObserver() {
if (liveObserver) { liveObserver.disconnect(); liveObserver = null; }
}
// Legacy stubs so existing call sites don't break
function startHospitalCountdowns() {} // now handled per-member inside syncFromDOM
function stopHospitalCountdowns() {} // handled by stopLiveObserver
// ─────────────────────────────────────────────
// TORN API HELPERS
// ─────────────────────────────────────────────
// Simple promise wrapper for GM_xmlhttpRequest
function apiGet(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
if (data.error) {
const code = data.error.code;
if (code === 5 || code === 8) {
showToast(`⚠ Torn API rate limit hit (error ${code}). Slow down.`, 'error');
}
reject(new Error(`Torn API error ${code}: ${data.error.error}`));
} else {
resolve(data);
}
} catch(e) { reject(e); }
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Request timed out')),
});
});
}
// Get faction ID from the URL if present (e.g. factions.php?step=profile&ID=39756)
// Returns null when on step=your (no ID in URL) — that's fine, we handle it below.
function getMyFactionId() {
const m = location.href.match(/[?&]ID=(\d+)/);
return m ? m[1] : null;
}
// Get YOUR player ID from the hidden torn-user input Torn embeds on every page
function getMyPlayerId() {
try {
const el = document.getElementById('torn-user');
if (el) return JSON.parse(el.value).id;
} catch {}
return null;
}
// ─────────────────────────────────────────────
// MEMBER LOADING — API ONLY (page is pure React SPA, no HTML members)
// ─────────────────────────────────────────────
async function parseEnemyMembers() {
if (!settings.tornKey) {
showError('No Torn API key set. Open Settings ⚙ and add your key.');
return [];
}
// ── Step 1: Get your faction ID ──
// Source A: ID= in the URL (works on step=profile&ID=39756)
// Source B: torn-user DOM element Torn embeds on every page (works on step=your)
// Source C: API call user?selections=profile as last resort
updateLoadingMsg('Step 1/3: Identifying your faction…');
let myFid = getMyFactionId(); // Source A — fast, free, works when ID is in URL
if (!myFid) {
// Source B: Torn embeds a #twi-torn-user or similar — try the known hidden inputs
try {
// Torn stores the current user's faction ID in the page's redux store / hidden inputs.
// The most reliable DOM source is the faction link in the sidebar.
// e.g. <a href="/factions.php?step=profile&ID=39756">My Faction</a>
const factionLink = document.querySelector('a[href*="factions.php?step=profile&ID="]');
if (factionLink) {
const dm = factionLink.href.match(/ID=(\d+)/);
if (dm) myFid = dm[1];
}
} catch {}
}
if (!myFid) {
// Source C: API call — user?selections=profile always includes faction.faction_id
try {
const profileData = await apiGet(
`https://api.torn.com/user/?selections=profile&key=${settings.tornKey}`
);
// v1 profile nests faction info under a "faction" object
if (profileData?.faction?.faction_id && profileData.faction.faction_id !== 0) {
myFid = String(profileData.faction.faction_id);
} else if (profileData?.faction_id && profileData.faction_id !== 0) {
myFid = String(profileData.faction_id);
}
if (!myFid) {
showError(`Could not find your faction ID. Are you in a faction? API said: ${JSON.stringify(profileData).slice(0, 200)}`);
return [];
}
} catch(e) {
showError(`Step 1 failed — ${e.message}`);
return [];
}
}
// ── Step 2: Get active ranked war via v2/faction/{id}/rankedwars ──
updateLoadingMsg('Step 2/3: Fetching active ranked war…');
let enemyFactionId = null;
try {
const rwData = await apiGet(
`https://api.torn.com/v2/faction/${myFid}/rankedwars?key=${settings.tornKey}`
);
// v2 response: { rankedwars: [ { id, factions: [{id, name, score}, ...], end, start, ... } ] }
const wars = rwData.rankedwars || [];
for (const war of wars) {
// Active war has no end timestamp (0 or null)
if (!war.end || war.end === 0) {
const factions = war.factions || [];
const enemy = factions.find(f => String(f.id) !== String(myFid));
if (enemy) { enemyFactionId = String(enemy.id); break; }
}
}
if (!enemyFactionId) {
showError(`No active ranked war found. Response: ${JSON.stringify(rwData).slice(0, 200)}`);
return [];
}
} catch(e) {
showError(`Step 2 failed — rankedwars error: ${e.message}`);
return [];
}
// ── Step 3: Fetch enemy faction members via v2/faction/{id}/members ──
updateLoadingMsg(`Step 3/3: Fetching members of enemy faction #${enemyFactionId}…`);
let factionData;
try {
factionData = await apiGet(
`https://api.torn.com/v2/faction/${enemyFactionId}/members?key=${settings.tornKey}`
);
} catch(e) {
showError(`Step 3 failed — members fetch error: ${e.message}`);
return [];
}
// Handle both v2 (array) and v1 (object) member formats
// v2: { members: [ {id, name, level, status:{state}, ...}, ... ] }
// v1: { members: { "xid": {name, level, status:{state}, ...}, ... } }
let rawMembers = factionData.members;
if (!rawMembers) {
showError(`No members found. Full response: ${JSON.stringify(factionData).slice(0, 200)}`);
return [];
}
// Normalise to array of [xid, memberObj]
let memberEntries = [];
if (Array.isArray(rawMembers)) {
// v2 array format
memberEntries = rawMembers.map(m => [String(m.id || m.player_id || '?'), m]);
} else {
// v1 object format
memberEntries = Object.entries(rawMembers);
}
updateLoadingMsg(`Building member list (${memberEntries.length} members)…`);
const members = memberEntries.map(([xid, m]) => {
let status = 'unknown';
const st = (m.status?.state || m.status?.description || m.status || '').toLowerCase();
if (st.includes('okay') || st === 'alive') status = 'okay';
else if (st.includes('hospital')) status = 'hospital';
else if (st.includes('travel')) status = 'traveling';
else if (st.includes('abroad')) status = 'abroad';
else if (st.includes('federal') || st.includes('jail')) status = 'jail';
// last_action.status = "Online" | "Idle" | "Offline" (added in Torn API patch #130)
const apiOnline = (m.last_action?.status || '').toLowerCase();
return {
xid: String(xid),
name: m.name || `#${xid}`,
level: String(m.level || ''),
score: '',
status,
apiOnline,
lastAction: m.last_action?.relative || m.lastAction?.relative || '',
};
}).filter(m => m.xid !== '?');
return members;
}
function updateLoadingMsg(msg) {
const container = document.getElementById('twi-member-list');
if (container) {
container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon" style="animation:twi-spin 1s linear infinite;display:inline-block">↻</div><br><span style="font-size:11px;color:var(--twi-text)">${msg}</span></div>`;
}
}
function showError(msg) {
const container = document.getElementById('twi-member-list');
if (container) {
container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">⚠</div><span style="color:var(--twi-accent2);font-size:12px;">${msg}</span></div>`;
}
showToast(msg.slice(0, 60), 'error');
}
// ─────────────────────────────────────────────
// RENDER MEMBER LIST
// ─────────────────────────────────────────────
let currentMembers = [];
function renderMemberList(members) {
if (members) currentMembers = members;
const list = currentMembers;
const container = document.getElementById('twi-member-list');
if (!container) return;
if (!list || list.length === 0) {
container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">⚔</div>No enemy members found.<br>Make sure you're on the war page.</div>`;
return;
}
// Update summary
const counts = { okay: 0, hospital: 0, away: 0, unknown: 0 };
list.forEach(m => {
if (m.status === 'okay') counts.okay++;
else if (m.status === 'hospital') counts.hospital++;
else if (m.status === 'abroad' || m.status === 'traveling') counts.away++;
else counts.unknown++;
});
document.getElementById('twi-count-okay').textContent = counts.okay;
document.getElementById('twi-count-hosp').textContent = counts.hospital;
document.getElementById('twi-count-away').textContent = counts.away;
document.getElementById('twi-count-unk').textContent = counts.unknown;
// Sort
let sorted = [...list];
const statusOrder = { okay: 0, hospital: 1, traveling: 2, abroad: 3, unknown: 4 };
if (sortMode === 'default') {
// keep original order
} else if (['okay', 'hospital', 'abroad', 'traveling'].includes(sortMode)) {
sorted.sort((a, b) => {
if (a.status === sortMode && b.status !== sortMode) return -1;
if (b.status === sortMode && a.status !== sortMode) return 1;
return 0;
});
} else if (sortMode === 'score') {
sorted.sort((a, b) => parseFloat(b.score || 0) - parseFloat(a.score || 0));
} else if (sortMode === 'battle') {
sorted.sort((a, b) => {
const ba = getBattleStats(b.xid);
const aa = getBattleStats(a.xid);
return (ba || 0) - (aa || 0);
});
}
container.innerHTML = '';
sorted.forEach(member => {
const card = buildMemberCard(member);
container.appendChild(card);
});
applyFiltersToDOM();
}
function getBattleStats(xid) {
const data = cachedStats[xid];
if (!data) return null;
const { str, spd, def, dex } = data;
if (str && spd && def && dex) return str + spd + def + dex;
return null;
}
function buildMemberCard(member) {
const card = document.createElement('div');
card.className = 'twi-member-card';
card.dataset.xid = member.xid;
card.dataset.status = member.status;
const data = cachedStats[member.xid] || {};
const { str, spd, def, dex, source } = data;
const battleRaw = (str && spd && def && dex) ? (str + spd + def + dex) : null;
const battleFmt = battleRaw ? formatStat(battleRaw) : null;
const battleFull = battleRaw ? formatStatFull(battleRaw) : null;
// Attack URL: torn.com/loader.php?sid=attack&user2ID=XID
const attackUrl = `https://www.torn.com/loader.php?sid=attack&user2ID=${member.xid}`;
const profileUrl = `https://www.torn.com/profiles.php?XID=${member.xid}`;
// Pull live state if already synced from DOM
const ms = memberState[member.xid];
const liveOnline = ms?.online || member.apiOnline || 'offline';
const liveStatus = ms?.domStatus || member.status;
card.innerHTML = `
<div class="twi-member-header">
<div class="twi-online-dot ${liveOnline}" title="${capitalize(liveOnline)}"></div>
<div class="twi-status-dot ${liveStatus}"></div>
<span class="twi-member-name">${member.name}</span>
${member.level ? `<span class="twi-member-level">Lv${member.level}</span>` : ''}
<span class="twi-battle-inline ${battleFmt ? '' : 'unknown'}" title="${battleFull ? 'Total: ' + battleFull : 'No stats yet'}">${battleFmt || '? BS'}</span>
<span class="twi-status-badge ${liveStatus}">${capitalize(liveStatus)}</span>
<div class="twi-action-btns">
<a class="twi-action-btn profile" href="${profileUrl}" target="_blank" title="View Profile">👤</a>
<a class="twi-action-btn attack" href="${attackUrl}" target="_blank" title="Attack player">⚔ ATK</a>
</div>
<span class="twi-expand-icon">▼</span>
</div>
<div class="twi-stats-body">
${data.loading ? '<div class="twi-loading-bar"></div>' : ''}
<div class="twi-stats-grid">
<div class="twi-stat-box">
<div class="twi-stat-label">BATTLE STATS</div>
<div class="twi-stat-value ${battleFmt ? '' : 'unknown'}" title="${battleFull || ''}">${battleFmt || '?'}</div>
</div>
<div class="twi-stat-box">
<div class="twi-stat-label">STR</div>
<div class="twi-stat-value ${str ? '' : 'unknown'}" title="${str ? formatStatFull(str) : ''}">${str ? formatStat(str) : '?'}</div>
</div>
<div class="twi-stat-box">
<div class="twi-stat-label">SPD</div>
<div class="twi-stat-value ${spd ? '' : 'unknown'}" title="${spd ? formatStatFull(spd) : ''}">${spd ? formatStat(spd) : '?'}</div>
</div>
<div class="twi-stat-box">
<div class="twi-stat-label">DEF</div>
<div class="twi-stat-value ${def ? '' : 'unknown'}" title="${def ? formatStatFull(def) : ''}">${def ? formatStat(def) : '?'}</div>
</div>
<div class="twi-stat-box">
<div class="twi-stat-label">DEX</div>
<div class="twi-stat-value ${dex ? '' : 'unknown'}" title="${dex ? formatStatFull(dex) : ''}">${dex ? formatStat(dex) : '?'}</div>
</div>
<div class="twi-stat-box">
<div class="twi-stat-label">PLAYER ID</div>
<div class="twi-stat-value" style="font-size:11px;color:var(--twi-text-dim)">${member.xid}</div>
</div>
</div>
${source ? `<div class="twi-stat-source">Source: ${source}</div>` : ''}
${!source && !data.loading ? `<div class="twi-stat-source">No stat data — configure APIs in Settings</div>` : ''}
</div>
`;
// Only expand/collapse on clicking the header area, not buttons
card.querySelector('.twi-member-header').addEventListener('click', (e) => {
if (e.target.closest('.twi-action-btns')) return;
card.classList.toggle('expanded');
});
return card;
}
function updateMemberCard(xid) {
const card = document.querySelector(`.twi-member-card[data-xid="${xid}"]`);
if (!card) return;
const member = currentMembers.find(m => m.xid === xid);
if (!member) return;
const newCard = buildMemberCard(member);
const wasExpanded = card.classList.contains('expanded');
if (wasExpanded) newCard.classList.add('expanded');
card.replaceWith(newCard);
}
// ─────────────────────────────────────────────
// FILTERS
// ─────────────────────────────────────────────
// Strip commas and parse shorthand like "1.5m", "500k"
function parseFilterVal(raw) {
if (!raw) return '';
const s = raw.toLowerCase().replace(/,/g, '').trim();
if (s.endsWith('b')) return String(parseFloat(s) * 1e9);
if (s.endsWith('m')) return String(parseFloat(s) * 1e6);
if (s.endsWith('k')) return String(parseFloat(s) * 1e3);
return s;
}
function applyFilters() {
const ids = ['min-battle','max-battle','min-str','max-str','min-spd','max-spd','min-def','max-def','min-dex','max-dex'];
const keys = ['minBattle','maxBattle','minStr','maxStr','minSpd','maxSpd','minDef','maxDef','minDex','maxDex'];
ids.forEach((id, i) => {
activeFilters[keys[i]] = parseFilterVal(document.getElementById('f-' + id).value);
});
activeFilters.hideUnknown = document.getElementById('f-hide-unknown').checked;
applyFiltersToDOM();
showToast('Filters applied', 'info');
}
function resetFilters() {
activeFilters = { ...defaultFilters };
document.getElementById('f-min-battle').value = '';
document.getElementById('f-max-battle').value = '';
document.getElementById('f-min-str').value = '';
document.getElementById('f-max-str').value = '';
document.getElementById('f-min-spd').value = '';
document.getElementById('f-max-spd').value = '';
document.getElementById('f-min-def').value = '';
document.getElementById('f-max-def').value = '';
document.getElementById('f-min-dex').value = '';
document.getElementById('f-max-dex').value = '';
document.getElementById('f-hide-unknown').checked = false;
applyFiltersToDOM();
showToast('Filters reset', 'info');
}
function applyFiltersToDOM() {
const cards = document.querySelectorAll('.twi-member-card');
cards.forEach(card => {
const xid = card.dataset.xid;
const data = cachedStats[xid] || {};
const { str, spd, def, dex } = data;
const hasStats = !!(str || spd || def || dex);
const battle = (str && spd && def && dex) ? str + spd + def + dex : null;
let hide = false;
if (activeFilters.hideUnknown && !hasStats) { hide = true; }
if (!hide && battle !== null) {
if (activeFilters.minBattle && battle < parseFloat(activeFilters.minBattle)) hide = true;
if (activeFilters.maxBattle && battle > parseFloat(activeFilters.maxBattle)) hide = true;
}
if (!hide && str !== undefined) {
if (activeFilters.minStr && (str || 0) < parseFloat(activeFilters.minStr)) hide = true;
if (activeFilters.maxStr && (str || 0) > parseFloat(activeFilters.maxStr)) hide = true;
}
if (!hide && spd !== undefined) {
if (activeFilters.minSpd && (spd || 0) < parseFloat(activeFilters.minSpd)) hide = true;
if (activeFilters.maxSpd && (spd || 0) > parseFloat(activeFilters.maxSpd)) hide = true;
}
if (!hide && def !== undefined) {
if (activeFilters.minDef && (def || 0) < parseFloat(activeFilters.minDef)) hide = true;
if (activeFilters.maxDef && (def || 0) > parseFloat(activeFilters.maxDef)) hide = true;
}
if (!hide && dex !== undefined) {
if (activeFilters.minDex && (dex || 0) < parseFloat(activeFilters.minDex)) hide = true;
if (activeFilters.maxDex && (dex || 0) > parseFloat(activeFilters.maxDex)) hide = true;
}
card.classList.toggle('hidden', hide);
});
}
// ─────────────────────────────────────────────
// CACHE TAB
// ─────────────────────────────────────────────
function renderCacheTab() {
const entries = Object.entries(cachedStats);
const info = document.getElementById('twi-cache-info');
const list = document.getElementById('twi-cache-list');
const size = JSON.stringify(cachedStats).length;
info.innerHTML = `
Cached players: <span style="color:var(--twi-accent)">${entries.length}</span><br>
Storage size: <span style="color:var(--twi-accent)">${(size / 1024).toFixed(1)} KB</span><br>
Last refresh: <span style="color:var(--twi-accent)">${localStorage.getItem('TWI_last_refresh') || 'Never'}</span>
`;
if (entries.length === 0) {
list.innerHTML = '<div style="color:var(--twi-text-dim);margin-top:8px;">No cached data yet.</div>';
return;
}
list.innerHTML = entries.map(([xid, data]) => {
const battle = data.str && data.spd && data.def && data.dex
? formatStat(data.str + data.spd + data.def + data.dex) : '?';
return `<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--twi-border)">
<span style="color:var(--twi-text)">${data.name || xid}</span>
<span style="color:var(--twi-accent)">${battle} [${data.source || '?'}]</span>
</div>`;
}).join('');
}
// ─────────────────────────────────────────────
// API FETCHING
// ─────────────────────────────────────────────
async function refreshStats(force = false) {
const members = currentMembers;
if (!members || members.length === 0) {
const parsed = await parseEnemyMembers();
if (parsed.length > 0) {
renderMemberList(parsed);
return refreshStats(force);
}
showToast('No enemy members found — add Torn API key in Settings', 'error');
return;
}
const refreshIcon = document.getElementById('twi-refresh-stats-btn');
if (refreshIcon) refreshIcon.classList.add('twi-refreshing');
let fetched = 0;
const toFetch = force ? members : members.filter(m => !cachedStats[m.xid]);
showToast(`Fetching stats for ${toFetch.length} members…`, 'info');
for (const member of toFetch) {
cachedStats[member.xid] = { ...cachedStats[member.xid], loading: true, name: member.name };
updateMemberCard(member.xid);
const statData = await fetchAllSources(member.xid);
cachedStats[member.xid] = { ...statData, name: member.name, loading: false };
updateMemberCard(member.xid);
fetched++;
}
saveCache();
localStorage.setItem('TWI_last_refresh', new Date().toLocaleString());
if (refreshIcon) refreshIcon.classList.remove('twi-refreshing');
applyFiltersToDOM();
showToast(`Done! Updated ${fetched} members.`, 'success');
}
async function fetchAllSources(xid) {
const results = {};
const sources = [];
// Try all enabled APIs in parallel
const promises = [];
if (settings.tornstatsKey && settings.enableTornStats) {
promises.push(fetchTornStats(xid).then(d => { if (d) { Object.assign(results, d); sources.push('TornStats'); } }));
}
if (settings.yataKey && settings.enableYata) {
promises.push(fetchYata(xid).then(d => { if (d) { mergeStats(results, d); sources.push('YATA'); } }));
}
if (settings.ffscouterKey && settings.enableFFScouter) {
promises.push(fetchFFScouter(xid).then(d => { if (d) { mergeStats(results, d); sources.push('FFS'); } }));
}
await Promise.all(promises);
results.source = sources.join('+') || 'none';
return results;
}
function mergeStats(base, incoming) {
// Prefer higher values (more accurate from more recent scout)
for (const key of ['str', 'spd', 'def', 'dex']) {
if (incoming[key] && (!base[key] || incoming[key] > base[key])) {
base[key] = incoming[key];
}
}
}
function fetchTornStats(xid) {
return new Promise(resolve => {
if (!settings.tornstatsKey) return resolve(null);
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.tornstats.com/api/v2/${settings.tornstatsKey}/spy/user/${xid}`,
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
// TornStats response: data.spy.strength, data.spy.speed, data.spy.defense, data.spy.dexterity
if (data && data.spy) {
resolve({
str: data.spy.strength,
spd: data.spy.speed,
def: data.spy.defense,
dex: data.spy.dexterity,
});
} else resolve(null);
} catch { resolve(null); }
},
onerror: () => resolve(null),
});
});
}
function fetchYata(xid) {
return new Promise(resolve => {
if (!settings.yataKey) return resolve(null);
GM_xmlhttpRequest({
method: 'GET',
url: `https://yata.yt/api/v1/users/${xid}/spy/?key=${settings.yataKey}`,
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
// YATA: data.strength, data.speed, data.defense, data.dexterity
if (data && data.strength !== undefined) {
resolve({
str: data.strength,
spd: data.speed,
def: data.defense,
dex: data.dexterity,
});
} else resolve(null);
} catch { resolve(null); }
},
onerror: () => resolve(null),
});
});
}
function fetchFFScouter(xid) {
return new Promise(resolve => {
if (!settings.ffscouterKey) return resolve(null);
GM_xmlhttpRequest({
method: 'GET',
url: `https://ffscouter.com/api/v1/stats?torn_user_id=${xid}&key=${settings.ffscouterKey}`,
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
// FFScouter: data.strength, data.speed, data.defense, data.dexterity
if (data && data.strength !== undefined) {
resolve({
str: data.strength,
spd: data.speed,
def: data.defense,
dex: data.dexterity,
});
} else resolve(null);
} catch { resolve(null); }
},
onerror: () => resolve(null),
});
});
}
// ─────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────
function formatStat(n) {
if (n === undefined || n === null) return '?';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return n.toString();
}
// Full number with comma punctuation for tooltips and filter hints
function formatStatFull(n) {
if (n === undefined || n === null) return '';
return Math.round(n).toLocaleString();
}
// Auto-format filter input with commas as user types
function bindFilterFormatting() {
const filterIds = ['f-min-battle','f-max-battle','f-min-str','f-max-str',
'f-min-spd','f-max-spd','f-min-def','f-max-def','f-min-dex','f-max-dex'];
filterIds.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', () => {
// Allow digits, commas, k/m/b shorthand, decimal point
const raw = el.value.replace(/[^0-9.,kmb]/gi, '');
el.value = raw;
});
el.addEventListener('blur', () => {
// On blur, if it's a plain number, format with commas
const raw = el.value.replace(/,/g, '').trim();
const num = parseFloat(raw);
if (!isNaN(num) && !/[kmb]/i.test(raw)) {
el.value = num.toLocaleString();
}
});
el.addEventListener('focus', () => {
// On focus, strip commas for easy editing
el.value = el.value.replace(/,/g, '');
});
});
}
function capitalize(str) {
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
}
function showToast(message, type = 'info') {
const container = document.getElementById('twi-toast');
if (!container) return;
const item = document.createElement('div');
const icons = { success: '✓', error: '✕', info: '◈' };
item.className = `twi-toast-item ${type}`;
item.innerHTML = `<span>${icons[type] || '•'}</span>${message}`;
container.appendChild(item);
setTimeout(() => {
item.style.opacity = '0';
item.style.transition = 'opacity 0.3s';
setTimeout(() => item.remove(), 300);
}, 3000);
}
// ─────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────
function init() {
if (!location.href.includes('factions.php')) return;
// Remove old instance if re-navigated
['twi-panel', 'twi-toggle', 'twi-settings-overlay', 'twi-toast'].forEach(id => {
const el = document.getElementById(id);
if (el) el.remove();
});
injectStyles();
buildUI();
observePage();
// Auto-open panel on war tab — API-based so no need to wait for DOM
const isWarTab = location.href.includes('war') || location.hash.includes('war');
if (isWarTab) {
panelOpen = true;
const panel = document.getElementById('twi-panel');
if (panel) panel.classList.add('open');
setTimeout(() => {
showToast(`WARINTEL v${VERSION} loaded`, 'info');
loadMembers();
}, 500);
}
}
// MutationObserver watches for hash changes (React SPA navigation)
function observePage() {
let lastUrl = location.href;
const observer = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(init, 1200);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// Wait for page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 800);
}
})();