Faction activity + growth tracking, ranked-war opponent scouting, recruit scouting, and an organized-crime board. Read-only against the Torn API.
// ==UserScript==
// @name Torn Faction Command
// @namespace torn-advisor.local
// @version 2.0.1
// @description Faction activity + growth tracking, ranked-war opponent scouting, recruit scouting, and an organized-crime board. Read-only against the Torn API.
// @author -Versatility-
// @match https://www.torn.com/*
// @icon https://www.torn.com/favicon.ico
// @connect api.torn.com
// @connect ffscouter.com
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
/*
* WHAT THIS SCRIPT CAN AND CANNOT DO
*
* The Torn API is READ-ONLY. There is no endpoint to kick a member and no
* endpoint to send mail. Anything on this dashboard that looks like an action
* is really a STAGING step:
*
* Kick -> opens the faction controls tab and copies the member name, so
* you can find them in Torn's own list. You do the kick.
* Message -> stores a drafted message, opens Torn's compose page, and fills
* the box in for you. YOU press Send. Nothing is ever sent
* automatically, and the draft is discarded once it is used.
*
* That is deliberate as well as forced: these actions land on real people.
*
* FFScouter is a THIRD PARTY (ffscouter.com). Using it sends the player IDs you
* are scouting, plus your Torn API key, to that site. It is therefore OFF by
* default and has to be switched on in Settings - matching how dashboard.py in
* this project already treats it.
*/
(function () {
'use strict';
const VERSION = '2.0.0';
const API_V2 = 'https://api.torn.com/v2';
const API_V1 = 'https://api.torn.com';
const FF_API = 'https://ffscouter.com/api/v1';
const API_KEY_STORE = 'tfa_api_key';
const HISTORY_STORE = 'tfa_history_v1';
const TREND_STORE = 'tfa_trend_v1';
const SETTINGS_STORE = 'tfa_settings_v1';
const FF_STORE = 'tfa_ff_cache_v1';
const MAIL_STORE = 'tfa_pending_mail_v1';
const ITEM_STORE = 'tfa_items_v1';
const HISTORY_TTL = 6 * 60 * 60 * 1000;
const FF_TTL = 12 * 60 * 60 * 1000;
const REQUESTS_PER_MINUTE = 75;
const WINDOW_SECONDS = 30 * 86400;
const FF_BATCH = 50;
const TREND_MAX = 40;
// Only xantaken and timeplayed are required. Everything else is a bonus
// signal that some accounts simply do not expose, so a missing one must
// never fail the row.
const STAT_FIELDS = [
'xantaken', 'timeplayed', 'activestreak', 'bestactivestreak',
'energydrinkused', 'statenhancersused', 'refills',
'attackswon', 'respectforfaction', 'networth',
].join(',');
const DEFAULT_SETTINGS = {
ffEnabled: false,
inactiveDays: 7,
lowXanaxPerDay: 0.5,
minActiveHours30d: 5,
// Recruit "good enough" marks. 0 disables a mark.
recruitMinXanax: 0,
recruitMinStats: 0,
messageTemplate:
'Hi {name},\n\n' +
'Quick activity check-in from {faction}.\n\n' +
'Our tracker shows your last action was {last_action} ({days_inactive} days ago), ' +
'with {xanax_per_day} Xanax/day and {active_30d} of active time over the last 30 days.\n\n' +
'Are you still playing? If you need time off just let us know and we will note it. ' +
'If we do not hear back we may need to free the slot up.\n\n' +
'Thanks!',
};
const requestTimes = [];
const state = {
tab: 'members',
members: [],
faction: {},
me: null,
isOwner: false,
stats: new Map(),
ff: new Map(),
filter: 'all',
search: '',
sort: 'activity',
loading: false,
scanToken: 0,
war: { opponentId: '', faction: null, members: [], error: '', loading: false },
recruits: { input: '', rows: [], error: '', loading: false, onlyMatches: false },
browse: { mode: 'factions', cat: 'respect', rows: [], offset: 0,
loading: false, error: '', freeOnly: false },
// `loaded` is not redundant with crimes.length: a faction with no crimes
// in progress legitimately returns an empty array, and without this flag
// the auto-load in renderTab() would re-fire on every single render.
oc: { crimes: [], error: '', loading: false, loaded: false,
mine: null, mineError: '' },
itemNames: new Map(),
};
let host;
let root;
let launcher;
let lastRecruitRender = 0;
/* ---------------------------------------------------------------- utils */
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[char]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const num = (value, fallback = 0) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
function settings() {
const saved = GM_getValue(SETTINGS_STORE, null);
const parsed = typeof saved === 'string' ? safeParse(saved) : saved;
return { ...DEFAULT_SETTINGS, ...(parsed && typeof parsed === 'object' ? parsed : {}) };
}
function saveSettings(patch) {
GM_setValue(SETTINGS_STORE, JSON.stringify({ ...settings(), ...patch }));
}
function safeParse(text) {
try { return JSON.parse(text); } catch (_) { return null; }
}
function statusKey(member) {
const value = String(member?.last_action?.status || 'unknown').toLowerCase();
return ['online', 'idle', 'offline'].includes(value) ? value : 'other';
}
function activeTime(seconds) {
const total = Math.max(0, Math.round(num(seconds)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (hours >= 24) return `${Math.floor(hours / 24).toLocaleString()}d ${hours % 24}h`;
if (hours) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function relativeTime(timestamp) {
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - num(timestamp));
if (!num(timestamp)) return 'unknown';
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
function daysSince(timestamp) {
if (!num(timestamp)) return null;
return Math.max(0, Math.floor((Date.now() / 1000 - num(timestamp)) / 86400));
}
function untilText(timestamp) {
const ts = num(timestamp);
if (!ts) return '—';
const secs = ts - Math.floor(Date.now() / 1000);
if (secs <= 0) return 'ready now';
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
const mins = Math.floor((secs % 3600) / 60);
if (days) return `in ${days}d ${hours}h`;
if (hours) return `in ${hours}h ${mins}m`;
return `in ${mins}m`;
}
function shortNumber(value) {
const n = num(value);
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(2)}b`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(2)}m`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return String(Math.round(n));
}
function copyText(text, button) {
const value = String(text ?? '');
try {
if (typeof GM_setClipboard === 'function') GM_setClipboard(value, 'text');
else navigator.clipboard?.writeText(value);
} catch (_) {
navigator.clipboard?.writeText(value).catch(() => {});
}
if (!button) return;
const original = button.dataset.original || button.textContent;
button.dataset.original = original;
button.textContent = 'Copied';
button.classList.add('copied');
setTimeout(() => {
button.textContent = original;
button.classList.remove('copied');
}, 1100);
}
// Rendered next to any name/ID we show, which is the "quick copy" ask.
function copyBtn(value, label = 'Copy') {
return `<button class="mini" data-copy="${escapeHtml(value)}" title="Copy ${escapeHtml(value)}">${escapeHtml(label)}</button>`;
}
function bindCopy(scope) {
scope.querySelectorAll('[data-copy]').forEach((button) => {
if (button.dataset.copyBound) return;
button.dataset.copyBound = '1';
button.addEventListener('click', (event) => {
event.preventDefault();
copyText(button.dataset.copy, button);
});
});
}
/* ------------------------------------------------------------------ api */
async function waitForApiSlot() {
while (true) {
const now = Date.now();
while (requestTimes.length && now - requestTimes[0] >= 60000) requestTimes.shift();
if (requestTimes.length < REQUESTS_PER_MINUTE) {
requestTimes.push(now);
return;
}
await sleep(Math.max(250, 60250 - (now - requestTimes[0])));
}
}
function apiKey() {
return String(GM_getValue(API_KEY_STORE, '') || '').trim();
}
function gmRequest(details) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
...details,
anonymous: true,
timeout: 30000,
onload: resolve,
onerror: () => reject(new Error('Network request failed')),
ontimeout: () => reject(new Error('Request timed out')),
onabort: () => reject(new Error('Request was cancelled')),
});
});
}
function parseBody(response) {
try {
return response.response && typeof response.response === 'object'
? response.response
: JSON.parse(response.responseText || '{}');
} catch (_) {
throw new Error(`Unreadable response (${response.status || 'no status'}).`);
}
}
async function api(path, params = {}) {
const key = apiKey();
if (!key) throw new Error('Enter a Torn API key first.');
await waitForApiSlot();
const query = new URLSearchParams({ ...params, comment: 'torn-faction-command' });
const response = await gmRequest({
method: 'GET',
url: `${API_V2}/${String(path).replace(/^\/+/, '')}?${query}`,
headers: { Authorization: `ApiKey ${key}`, Accept: 'application/json' },
});
const payload = parseBody(response);
if (response.status < 200 || response.status >= 300) {
throw new Error(`Torn API HTTP ${response.status}: ${response.statusText || 'request failed'}`);
}
if (payload?.error) {
throw new Error(`Torn API error ${payload.error.code ?? '?'}: ${payload.error.error || 'Unknown error'}`);
}
return payload;
}
// v1 is still the cleanest way to pull an ARBITRARY faction's roster in a
// single request (faction/<id>?selections=basic), which is exactly what war
// and recruit scouting need.
async function apiV1(path, selections = [], params = {}) {
const key = apiKey();
if (!key) throw new Error('Enter a Torn API key first.');
await waitForApiSlot();
const query = new URLSearchParams({
...params,
selections: selections.join(','),
key,
comment: 'torn-faction-command',
});
const response = await gmRequest({
method: 'GET',
url: `${API_V1}/${String(path).replace(/^\/+/, '')}?${query}`,
headers: { Accept: 'application/json' },
});
const payload = parseBody(response);
if (payload?.error) {
throw new Error(`Torn API error ${payload.error.code ?? '?'}: ${payload.error.error || 'Unknown error'}`);
}
return payload;
}
/* ----------------------------------------------------------- ffscouter */
function ffCache() {
const cache = safeParse(GM_getValue(FF_STORE, '{}')) || {};
const now = Date.now();
for (const id of Object.keys(cache)) {
if (now - num(cache[id]?.cached_at) > FF_TTL) delete cache[id];
}
return cache;
}
function saveFfRows(rows) {
const cache = ffCache();
const now = Date.now();
for (const row of rows) {
if (!row?.player_id) continue;
cache[String(row.player_id)] = { ...row, cached_at: now };
}
GM_setValue(FF_STORE, JSON.stringify(cache));
}
/**
* Batched FFScouter lookup. Mirrors dashboard.py: /get-stats with a comma
* list of targets, 50 at a time, and the response may be a bare array or
* {targets:[...]}. Returns a Map keyed by player id.
*/
async function ffLookup(ids, onProgress) {
const out = new Map();
if (!settings().ffEnabled) return out;
const key = apiKey();
if (!key) return out;
const cache = ffCache();
const missing = [];
for (const raw of ids) {
const id = Number(raw);
if (!Number.isFinite(id) || id <= 0) continue;
const hit = cache[String(id)];
if (hit) out.set(id, hit);
else if (!missing.includes(id)) missing.push(id);
}
for (let start = 0; start < missing.length; start += FF_BATCH) {
const batch = missing.slice(start, start + FF_BATCH);
try {
const response = await gmRequest({
method: 'GET',
url: `${FF_API}/get-stats?key=${encodeURIComponent(key)}&targets=${batch.join(',')}`,
headers: { Accept: 'application/json' },
});
const payload = parseBody(response);
const rows = Array.isArray(payload)
? payload
: (Array.isArray(payload?.targets) ? payload.targets : []);
saveFfRows(rows);
for (const row of rows) {
if (row?.player_id) out.set(Number(row.player_id), row);
}
} catch (_) {
// A failed FFScouter batch must never break the Torn-side view.
}
if (onProgress) onProgress(Math.min(start + FF_BATCH, missing.length), missing.length);
}
return out;
}
function ffConfidence(row) {
if (!row || !num(row.bs_estimate)) return 'unknown';
const source = String(row.source || 'bss').toLowerCase();
const age = Math.max(0, Math.floor(Date.now() / 1000) - num(row.last_updated || row.bss_public_timestamp));
if (['spies', 'premium'].includes(source) && age <= 30 * 86400) return 'high';
if (age <= 14 * 86400) return 'medium';
return 'low';
}
function ffCell(row) {
if (!settings().ffEnabled) return '<span class="muted">FF off</span>';
if (!row) return '<span class="muted">—</span>';
const estimate = num(row.bs_estimate);
const fair = num(row.fair_fight);
if (!estimate && !fair) return '<span class="muted">No data</span>';
const conf = ffConfidence(row);
return `<span class="stat-main">${estimate ? shortNumber(estimate) : '—'}</span>`
+ `<div class="muted">${fair ? `FF ${fair.toFixed(2)} · ` : ''}<span class="conf ${conf}">${conf}</span></div>`;
}
/* --------------------------------------------------- history and growth */
function historyCache() {
const cache = safeParse(GM_getValue(HISTORY_STORE, '{}')) || {};
return cache && typeof cache === 'object' ? cache : {};
}
function saveHistoryRow(row) {
const cache = historyCache();
cache[String(row.id)] = { ...row, cached_at: Date.now() };
GM_setValue(HISTORY_STORE, JSON.stringify(cache));
}
function cachedHistoryRow(id) {
const row = historyCache()[String(id)];
if (!row) return null;
return Date.now() - num(row.cached_at) < HISTORY_TTL ? row : null;
}
/**
* A rolling snapshot log, so "growth" can eventually mean a real trend
* rather than a single 30-day window. It fills in as the dashboard is used;
* a delta only appears once two snapshots are at least 3 days apart.
*/
function recordTrend(row) {
const store = safeParse(GM_getValue(TREND_STORE, '{}')) || {};
const id = String(row.id);
const list = Array.isArray(store[id]) ? store[id] : [];
const last = list[list.length - 1];
const point = {
t: Math.floor(Date.now() / 1000),
xan: num(row.xanax_30d),
active: num(row.active_seconds_30d),
streak: num(row.login_streak),
};
// One point per 12h is plenty and keeps the store small.
if (!last || point.t - num(last.t) >= 43200) {
list.push(point);
store[id] = list.slice(-TREND_MAX);
GM_setValue(TREND_STORE, JSON.stringify(store));
}
}
function trendFor(id) {
const store = safeParse(GM_getValue(TREND_STORE, '{}')) || {};
const list = Array.isArray(store[String(id)]) ? store[String(id)] : [];
if (list.length < 2) return null;
const latest = list[list.length - 1];
const older = [...list].reverse().find((point) => num(latest.t) - num(point.t) >= 3 * 86400);
if (!older) return null;
return {
days: Math.round((num(latest.t) - num(older.t)) / 86400),
xanax: num(latest.xan) - num(older.xan),
active: num(latest.active) - num(older.active),
};
}
function statsByName(payload) {
const rows = Array.isArray(payload?.personalstats)
? payload.personalstats
: Object.values(payload?.personalstats || {});
return Object.fromEntries(rows.filter((row) => row?.name).map((row) => [
String(row.name).toLowerCase(),
{ value: num(row.value), timestamp: num(row.timestamp) },
]));
}
async function loadMemberHistory(member) {
const currentPayload = await api(`user/${member.id}/personalstats`, { stat: STAT_FIELDS });
const current = statsByName(currentPayload);
const currentTimestamp = Math.max(0, ...Object.values(current).map((item) => item.timestamp));
if (!currentTimestamp || !current.xantaken || !current.timeplayed) {
throw new Error('Required public personal stats were not returned.');
}
const historicalPayload = await api(`user/${member.id}/personalstats`, {
stat: 'xantaken,timeplayed,energydrinkused,statenhancersused,attackswon,respectforfaction',
timestamp: currentTimestamp - WINDOW_SECONDS,
});
const historical = statsByName(historicalPayload);
const historicalTimestamp = Math.max(0, ...Object.values(historical).map((item) => item.timestamp));
if (!historicalTimestamp || !historical.xantaken || !historical.timeplayed) {
throw new Error('No 30-day historical snapshot is available.');
}
const windowDays = Math.max(1, (currentTimestamp - historicalTimestamp) / 86400);
const delta = (name) => Math.max(0, num(current[name]?.value) - num(historical[name]?.value));
const xanax30 = delta('xantaken');
const active30 = delta('timeplayed');
const row = {
id: Number(member.id),
available: true,
xanax_30d: xanax30,
xanax_per_day: Math.round((xanax30 / windowDays) * 100) / 100,
active_seconds_30d: active30,
active_hours_per_day: Math.round((active30 / 3600 / windowDays) * 100) / 100,
energy_30d: delta('energydrinkused'),
enhancers_30d: delta('statenhancersused'),
attacks_30d: delta('attackswon'),
respect_30d: delta('respectforfaction'),
networth: num(current.networth?.value),
login_streak: num(current.activestreak?.value),
best_login_streak: num(current.bestactivestreak?.value),
window_days: Math.round(windowDays * 10) / 10,
snapshot_at: currentTimestamp,
};
row.growth = growthScore(row);
return row;
}
/**
* A 0-100 "is this account actually growing" index.
*
* Deliberately activity-weighted rather than power-weighted: current battle
* stats tell you how strong someone is TODAY (that is what FFScouter is
* for), whereas Xanax burn, hours played and streak tell you whether they
* are still putting the work in. That is the question both for a member
* review and for a recruit.
*/
function growthScore(row) {
const cap = (value, max) => Math.min(1, Math.max(0, num(value) / max));
const score =
40 * cap(row.xanax_per_day, 3) +
30 * cap(row.active_hours_per_day, 6) +
20 * cap(row.login_streak, 100) +
10 * cap(row.enhancers_30d, 10);
return Math.round(score);
}
function growthBadge(score) {
const value = num(score);
const tier = value >= 70 ? 'high' : value >= 40 ? 'medium' : 'low';
return `<span class="growth ${tier}">${value}</span>`;
}
/* ------------------------------------------------------------ inactivity */
function inactivityFlags(member, history) {
const config = settings();
const flags = [];
const idle = daysSince(member?.last_action?.timestamp);
if (idle !== null && idle >= num(config.inactiveDays)) flags.push(`${idle}d since last action`);
if (history?.available) {
if (num(history.xanax_per_day) < num(config.lowXanaxPerDay)) {
flags.push(`${num(history.xanax_per_day).toFixed(2)} xan/day`);
}
if (num(history.active_seconds_30d) / 3600 < num(config.minActiveHours30d)) {
flags.push(`${(num(history.active_seconds_30d) / 3600).toFixed(1)}h active/30d`);
}
}
return flags;
}
function fillTemplate(member, history) {
const config = settings();
const idle = daysSince(member?.last_action?.timestamp);
const map = {
'{name}': member?.name || 'there',
'{id}': member?.id ?? '',
'{faction}': state.faction?.name || 'the faction',
'{last_action}': member?.last_action?.relative || relativeTime(member?.last_action?.timestamp),
'{days_inactive}': idle === null ? 'unknown' : String(idle),
'{xanax_per_day}': history?.available ? num(history.xanax_per_day).toFixed(2) : 'n/a',
'{active_30d}': history?.available ? activeTime(history.active_seconds_30d) : 'n/a',
'{streak}': history?.available ? String(num(history.login_streak)) : 'n/a',
'{position}': member?.position || 'Member',
};
return String(config.messageTemplate || '').replace(/\{[a-z_]+\}/g, (token) => (
Object.prototype.hasOwnProperty.call(map, token) ? map[token] : token
));
}
/**
* Stage a message. The API cannot send mail, so the draft is parked in
* script storage and Torn's own compose page is opened; the handler at the
* bottom of this file fills the box. The user still presses Send.
*/
function stageMessage(member, history) {
const body = fillTemplate(member, history);
GM_setValue(MAIL_STORE, JSON.stringify({
id: Number(member.id),
name: member.name,
body,
staged_at: Date.now(),
}));
window.open(`https://www.torn.com/messages.php#/p=compose&XID=${Number(member.id)}`, '_blank', 'noopener');
}
function stageKick(member, button) {
copyText(member.name, button);
window.open('https://www.torn.com/factions.php?step=your&type=1#/tab=controls', '_blank', 'noopener');
}
/**
* Torn's error strings are terse and, for code 7, actively misleading -
* "Incorrect ID-entity relation" reads like a bad ID when it actually
* means the key's FACTION POSITION lacks the API Access (AA) permission.
* Verified 2026-08-26 against a Full Access (level 4) key: /faction/crimes
* returns 7 for every value of `cat`, including none, while /faction/basic
* and /faction/rankedwars on the same key work fine.
*/
function friendlyApiError(error, context) {
const message = String(error?.message || error || '');
const code = Number((message.match(/Torn API error (\d+)/) || [])[1] || 0);
if (code === 7) {
return `${context}: your faction position (${escapeHtml(state.me?.position || 'unknown')}) does not have `
+ `the "API Access" permission, so Torn will not release this faction data. `
+ `A faction leader grants it per position under Faction → Controls → Permissions. `
+ `Nothing is wrong with your API key — it is Full Access endpoints like faction/basic that already work.`;
}
if (code === 6) return `${context}: Torn says that ID does not exist. Double-check the number.`;
if (code === 2) return `${context}: Torn rejected the API key. Re-enter it under “API key”.`;
if (code === 5) return `${context}: too many API requests — wait a minute and try again.`;
if (code === 9) return `${context}: the Torn API is in maintenance mode. Try later.`;
return `${context}: ${message}`;
}
/* --------------------------------------------------------------- styles */
function addStyles() {
const style = document.createElement('style');
style.textContent = `
:host { all: initial; color-scheme: dark; }
* { box-sizing: border-box; }
button, input, select, textarea { font: inherit; }
.launcher { position: fixed; right: 0; top: 150px; width: 52px; height: 42px; z-index: 2147483646;
border: 2px solid #d32979; border-right: 0; border-radius: 11px 0 0 11px;
background: linear-gradient(145deg,#211018,#25102f 58%,#17101f); color: #ff4c9e; padding: 0;
font: 800 12px/1 "Segoe UI",sans-serif; letter-spacing: .7px;
box-shadow: -7px 0 22px #a3156955, inset 0 0 12px #9e17552e; cursor: pointer;
transition: background .16s,border-color .16s,color .16s,box-shadow .16s; }
.launcher:hover { background: linear-gradient(145deg,#59152f,#4a176b); border-color: #f03b91;
color: #fff; box-shadow: -8px 0 25px #c31e7b88; }
.backdrop { position: fixed; inset: 0; z-index: 2147483647; background: #07090dcc; backdrop-filter: blur(5px);
display: grid; place-items: center; padding: 18px; font: 13px/1.45 "Segoe UI",system-ui,sans-serif; color: #e8ebf3; }
.hidden { display: none !important; }
.modal { width: min(1500px, 98vw); height: min(900px, 94vh); overflow: hidden; display: flex; flex-direction: column;
border: 1px solid #394765; border-radius: 15px; background: #11141b; box-shadow: 0 24px 90px #000d; }
.header { display: flex; align-items: center; gap: 12px; padding: 14px 17px; border-bottom: 1px solid #2b3140;
background: linear-gradient(135deg,#202b43,#171b24 62%); }
.brand { margin-right: auto; }
.eyebrow { color: #9fbaff; text-transform: uppercase; letter-spacing: 1px; font-size: 9px; font-weight: 800; }
h1 { margin: 2px 0 0; font-size: 19px; line-height: 1.2; color: #f2f5fb; }
h2 { margin: 0 0 8px; font-size: 20px; }
h3 { margin: 0 0 8px; font-size: 14px; color: #cfd7e8; }
.btn { border: 1px solid #363e50; border-radius: 7px; background: #181d27; color: #dce2ee; padding: 8px 11px; cursor: pointer; }
.btn:hover { border-color: #638ce4; color: #fff; }
.btn.primary { background: #315eb8; border-color: #5380dc; color: #fff; }
.btn.danger { border-color: #703b43; color: #ffc0c6; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.mini { border: 1px solid #39425a; border-radius: 5px; background: #161b25; color: #9fb2d4;
padding: 1px 6px; font-size: 9px; font-weight: 700; cursor: pointer; margin-left: 5px; vertical-align: middle; }
.mini:hover { border-color: #5f87d8; color: #fff; }
.mini.copied { border-color: #3d7a54; color: #8add9f; }
.mini.warn { border-color: #6c5427; color: #e8ba69; }
.mini.danger { border-color: #703b43; color: #ffc0c6; }
.tabs { display: flex; gap: 6px; padding: 10px 14px 0; border-bottom: 1px solid #2b3140; overflow-x: auto; }
.tab { border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; background: transparent;
color: #93a0b8; padding: 8px 13px; cursor: pointer; white-space: nowrap; font-weight: 700; }
.tab:hover { color: #dce4f4; }
.tab.active { background: #181d27; border-color: #333c4f; color: #fff; }
.content { flex: 1; overflow: auto; padding: 14px; }
.setup { max-width: 650px; margin: 40px auto; border: 1px solid #303747; border-radius: 13px; padding: 22px;
background: #181c25; }
.panel { border: 1px solid #303747; border-radius: 12px; padding: 16px; background: #171b24; margin-bottom: 12px; }
.muted { color: #8f98aa; }
.key-row { display: flex; gap: 8px; margin-top: 16px; }
.key-input, .search, .text-input { min-width: 0; border: 1px solid #363e50; border-radius: 7px;
background: #10131a; color: #eef2fa; padding: 9px 11px; }
.key-input { flex: 1; }
textarea.text-input { width: 100%; min-height: 150px; resize: vertical; font-family: inherit; line-height: 1.5; }
.field { margin-bottom: 12px; }
.field input.text-input, .field textarea.text-input { width: 100%; }
.field label { display: block; margin-bottom: 5px; font-size: 11px; font-weight: 700; color: #b9c4d8; }
.field label input[type="checkbox"] { width: auto; margin-right: 6px; vertical-align: middle; }
.field .hint { margin-top: 4px; font-size: 10px; color: #8f98aa; }
.inline-fields { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; }
.notice { border: 1px solid #3b4967; background: #151c29; color: #bfcbe1; border-radius: 9px; padding: 10px 12px; margin-top: 12px; }
.error { border-color: #743d45; background: #25171b; color: #ffc0c6; }
.warnbox { border-color: #6c5427; background: #221c11; color: #f0d4a0; }
.metrics { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 9px; margin-bottom: 11px; }
.metric { min-width: 0; border: 1px solid #2d3442; border-radius: 10px; background: #181c25; padding: 12px; }
.metric.good { border-color: #315b40; }
.metric.warn { border-color: #665027; }
.metric.bad { border-color: #703b43; }
.label { color: #8f98aa; text-transform: uppercase; letter-spacing: .7px; font-size: 9px; font-weight: 800; }
.value { margin-top: 4px; font-size: 19px; font-weight: 780; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sub { color: #8f98aa; margin-top: 3px; font-size: 10px; }
.progress-wrap { border: 1px solid #303849; border-radius: 10px; padding: 10px 12px; background: #151922; margin-bottom: 10px; }
.progress-line { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; color: #b9c4d8; }
.progress { height: 7px; margin-top: 8px; background: #0d1016; border-radius: 99px; overflow: hidden; }
.progress > i { display: block; height: 100%; background: linear-gradient(90deg,#4f7df0,#76a3ff); transition: width .2s; }
.toolbar { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
.filter.active { background: #263653; border-color: #5279c8; color: #fff; }
.search { width: 260px; margin-left: auto; }
.table-wrap { overflow: auto; border: 1px solid #2b3140; border-radius: 10px; background: #151820; }
table { width: 100%; border-collapse: collapse; color: #e5e9f1; }
th, td { padding: 9px 10px; border-bottom: 1px solid #282e3a; text-align: left; white-space: nowrap; font-size: 11px; }
th { position: sticky; top: 0; z-index: 2; background: #11151d; color: #929bad; text-transform: uppercase; letter-spacing: .55px; font-size: 9px; }
tbody tr:nth-child(even) { background: #ffffff05; }
tbody tr:hover { background: #202735; }
tbody tr.flagged { background: #2a1d13; }
tbody tr.flagged:hover { background: #35251a; }
.member { color: #eff3fb; font-weight: 750; text-decoration: none; }
.member:hover { color: #8fb0ff; }
.pill { display: inline-block; border: 1px solid #3b4354; border-radius: 999px; padding: 2px 7px; font-size: 9px; font-weight: 800; }
.pill.online { color: #8add9f; border-color: #315b40; background: #142019; }
.pill.idle { color: #e8ba69; border-color: #664d24; background: #211b12; }
.pill.offline, .pill.other { color: #a4adbd; }
.pill.hosp { color: #ff9aa6; border-color: #703b43; background: #221317; }
.pill.open { color: #ffc0c6; border-color: #703b43; background: #221317; }
.stat-main { font-weight: 750; color: #eef2fa; }
.growth { display: inline-block; min-width: 30px; text-align: center; border-radius: 5px; padding: 2px 6px; font-weight: 800; font-size: 10px; }
.growth.high { background: #14301f; color: #8add9f; border: 1px solid #315b40; }
.growth.medium { background: #2a2413; color: #e8ba69; border: 1px solid #665027; }
.growth.low { background: #241417; color: #ffa8b3; border: 1px solid #5e333b; }
.conf.high { color: #8add9f; }
.conf.medium { color: #e8ba69; }
.conf.low, .conf.unknown { color: #a4adbd; }
.empty { padding: 34px; text-align: center; color: #8f98aa; }
.row-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.slot { padding: 2px 0; }
.slot.open-slot { padding: 3px 6px; margin: 2px 0; border-left: 2px solid #703b43;
background: #ffffff06; border-radius: 0 5px 5px 0; }
.cpr { display: inline-block; margin-left: 4px; padding: 1px 5px; border-radius: 4px;
background: #14243a; border: 1px solid #33507d; color: #9fc0ff; font-size: 9px; font-weight: 800; }
.slot.me-slot { border-left: 2px solid #4f7df0; background: #4f7df015; padding: 3px 6px;
margin: 2px 0; border-radius: 0 5px 5px 0; }
.pill.you { color: #9fc0ff; border-color: #33507d; background: #14243a; }
tbody tr.match { background: #12291c; }
tbody tr.match:hover { background: #17331f; }
.have { color: #8add9f; font-size: 10px; }
.need { color: #ffa8b3; font-size: 10px; font-weight: 700; }
@media (max-width: 950px) {
.metrics { grid-template-columns: repeat(2,minmax(0,1fr)); }
.inline-fields { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.backdrop { padding: 0; place-items: stretch; }
.modal { width: 100vw; height: 100dvh; max-width: none; max-height: none; border: 0; border-radius: 0; }
.header { flex: 0 0 auto; gap: 7px; padding: max(10px,env(safe-area-inset-top)) 10px 10px; }
.brand { min-width: 0; }
.eyebrow { display: none; }
h1 { margin: 0; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.header .btn { flex: 0 0 auto; padding: 7px 9px; }
.tabs { padding: 8px 8px 0; }
.content { min-width: 0; padding: 9px 9px max(9px,env(safe-area-inset-bottom)); overscroll-behavior: contain; }
.setup, .panel { width: 100%; max-width: none; margin: 10px 0; padding: 16px; }
.progress-line { align-items: flex-start; }
.progress-line span:first-child { min-width: 0; }
.toolbar .filter { flex: 1 1 calc(25% - 7px); min-width: 65px; }
.search { order: 2; flex: 1 0 100%; width: 100%; margin-left: 0; }
.table-wrap { width: 100%; max-width: 100%; -webkit-overflow-scrolling: touch; }
}
@media (max-width: 600px) {
.launcher { top: 124px; }
.metrics { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 3px; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
.metric { flex: 0 0 145px; padding: 9px 10px; scroll-snap-align: start; }
.value { font-size: 17px; }
.key-row { flex-direction: column; }
.key-row .btn { width: 100%; }
.table-wrap { overflow: visible; border: 0; background: transparent; }
table.cards, table.cards tbody { display: block; width: 100%; }
table.cards thead { display: none; }
table.cards tbody { display: grid; gap: 8px; }
table.cards tbody tr { display: block; width: 100%; overflow: hidden;
border: 1px solid #30394b; border-radius: 10px; background: #151922; }
table.cards tbody tr:nth-child(even) { background: #181d27; }
table.cards th, table.cards td { white-space: normal; }
table.cards td { display: block; min-width: 0; padding: 6px 9px; text-align: left; overflow-wrap: anywhere; }
table.cards td::before { content: attr(data-label); display: block; margin-bottom: 2px; color: #91a6d5;
font-size: 8px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase; }
table.cards td .muted { margin-top: 2px; font-size: 9px; line-height: 1.3; }
table.cards td .stat-main, table.cards td .member { font-size: 11px; }
.empty { border: 1px solid #30394b; border-radius: 10px; }
}
@media (max-width: 360px) {
.metric { flex-basis: 135px; }
.header { padding-left: 8px; padding-right: 8px; }
h1 { font-size: 14px; }
}
`;
root.appendChild(style);
}
/* ---------------------------------------------------------------- shell */
function mountLauncher() {
host = document.createElement('div');
host.id = 'tfa-host';
root = host.attachShadow({ mode: 'open' });
addStyles();
launcher = document.createElement('button');
launcher.className = 'launcher';
launcher.textContent = 'FC';
launcher.title = 'Open Faction Command';
launcher.setAttribute('aria-label', 'Open Faction Command');
launcher.addEventListener('click', openDashboard);
root.appendChild(launcher);
document.documentElement.appendChild(host);
}
const TABS = [
['members', 'Members'],
['war', 'War scout'],
['recruits', 'Recruits'],
['oc', 'Crimes'],
['settings', 'Settings'],
];
function shell(content, withTabs = false) {
let backdrop = root.querySelector('.backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'backdrop';
root.appendChild(backdrop);
}
backdrop.classList.remove('hidden');
const tabs = withTabs
? `<div class="tabs">${TABS.map(([id, label]) => (
`<button class="tab ${state.tab === id ? 'active' : ''}" data-tab="${id}">${escapeHtml(label)}</button>`
)).join('')}</div>`
: '';
backdrop.innerHTML = `<div class="modal"><div class="header"><div class="brand">`
+ `<div class="eyebrow">Made by -Versatility- · v${VERSION}</div>`
+ `<h1>Faction Command</h1></div>`
+ `<button class="btn" data-action="settings">API key</button>`
+ `<button class="btn" data-action="close">Close</button></div>`
+ tabs
+ `<div class="content">${content}</div></div>`;
backdrop.querySelector('[data-action="close"]').addEventListener('click', closeDashboard);
backdrop.querySelector('[data-action="settings"]').addEventListener('click', () => renderSetup());
backdrop.querySelectorAll('[data-tab]').forEach((button) => button.addEventListener('click', () => {
state.tab = button.dataset.tab;
renderTab();
}));
return backdrop.querySelector('.content');
}
function closeDashboard() {
state.scanToken += 1;
state.loading = false;
root.querySelector('.backdrop')?.classList.add('hidden');
}
function renderSetup(message = '') {
state.scanToken += 1;
state.loading = false;
const saved = Boolean(apiKey());
const content = shell(`<div class="setup"><h2>${saved ? 'API key settings' : 'Connect your Torn account'}</h2>`
+ `<div class="muted">Use your own Torn API key. It is stored only inside this Tampermonkey script on this browser, and is sent to api.torn.com only.</div>`
+ (message ? `<div class="notice ${message.startsWith('Error:') ? 'error' : ''}">${escapeHtml(message)}</div>` : '')
+ `<div class="key-row"><input class="key-input" type="password" autocomplete="off" placeholder="${saved ? 'A key is already saved — enter a replacement only' : 'Paste Torn API key'}">`
+ `<button class="btn primary" data-action="save-key">${saved ? 'Save & reload' : 'Save & load'}</button></div>`
+ `<div class="notice">Public access is enough. This script is read-only against the Torn API: it cannot kick, mail, or perform any gameplay action. Anything that looks like an action opens Torn's own page for you to confirm.</div>`
+ (saved ? '<div style="margin-top:12px"><button class="btn danger" data-action="clear-key">Remove saved key and cached data</button></div>' : '')
+ `</div>`);
const input = content.querySelector('.key-input');
content.querySelector('[data-action="save-key"]').addEventListener('click', async () => {
const next = input.value.trim();
if (next) GM_setValue(API_KEY_STORE, next);
if (!apiKey()) return renderSetup('Error: Enter an API key.');
await loadDashboard(false);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') content.querySelector('[data-action="save-key"]').click();
});
content.querySelector('[data-action="clear-key"]')?.addEventListener('click', () => {
[API_KEY_STORE, HISTORY_STORE, TREND_STORE, FF_STORE, MAIL_STORE].forEach(GM_deleteValue);
renderSetup('Saved API key and cached data removed.');
});
input.focus();
}
/* -------------------------------------------------------- members tab */
function memberRowsMarkup() {
const query = state.search.trim().toLowerCase();
const visible = state.members.filter((member) => {
const history = state.stats.get(Number(member.id));
const flags = inactivityFlags(member, history);
let filterMatch = true;
if (state.filter === 'flagged') filterMatch = flags.length > 0;
else if (state.filter !== 'all') filterMatch = statusKey(member) === state.filter;
const haystack = `${member.name} ${member.id} ${member.position || ''} ${member.status?.state || ''}`.toLowerCase();
return filterMatch && (!query || haystack.includes(query));
});
const rows = visible.map((member) => {
const history = state.stats.get(Number(member.id));
const activity = statusKey(member);
const flags = inactivityFlags(member, history);
const ff = state.ff.get(Number(member.id));
const trend = trendFor(member.id);
let xanax = '<span class="muted">Queued…</span>';
let active = '<span class="muted">Queued…</span>';
let streak = '<span class="muted">Queued…</span>';
let growth = '<span class="muted">—</span>';
if (history?.available) {
xanax = `<span class="stat-main">${num(history.xanax_per_day).toFixed(2)}/day</span>`
+ `<div class="muted">${num(history.xanax_30d).toLocaleString()} in ${num(history.window_days, 30).toFixed(0)}d</div>`;
active = `<span class="stat-main">${activeTime(history.active_seconds_30d)}</span>`
+ `<div class="muted">${num(history.active_hours_per_day).toFixed(2)} h/day</div>`;
streak = `<span class="stat-main">${num(history.login_streak).toLocaleString()}d</span>`
+ `<div class="muted">best ${num(history.best_login_streak).toLocaleString()}d</div>`;
growth = growthBadge(history.growth)
+ `<div class="muted">${trend ? `${trend.xanax >= 0 ? '+' : ''}${trend.xanax} xan / ${trend.days}d` : 'trend building'}</div>`;
} else if (history) {
xanax = active = streak = `<span class="muted" title="${escapeHtml(history.error || 'Unavailable')}">Unavailable</span>`;
}
const actions = state.isOwner
? `<div class="row-actions">`
+ `<button class="mini warn" data-msg="${Number(member.id)}">Message</button>`
+ `<button class="mini danger" data-kick="${Number(member.id)}">Kick…</button></div>`
: '<span class="muted">—</span>';
return `<tr class="${flags.length ? 'flagged' : ''}">`
+ `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${Number(member.id)}">${escapeHtml(member.name)}</a>`
+ copyBtn(member.name, 'name')
+ `<div class="muted">ID ${Number(member.id)}${copyBtn(String(member.id), 'id')}</div></td>`
+ `<td>${num(member.level)}</td>`
+ `<td>${escapeHtml(member.position || 'Member')}</td>`
+ `<td><span class="pill ${activity}">${escapeHtml(member.last_action?.status || 'Unknown')}</span>`
+ `<div class="muted">${escapeHtml(member.last_action?.relative || relativeTime(member.last_action?.timestamp))}</div></td>`
+ `<td>${xanax}</td>`
+ `<td>${active}</td>`
+ `<td>${streak}</td>`
+ `<td>${growth}</td>`
+ `<td>${ffCell(ff)}</td>`
+ `<td>${flags.length ? `<span class="pill open">${escapeHtml(flags.join(' · '))}</span>` : '<span class="muted">ok</span>'}</td>`
+ `<td>${actions}</td></tr>`;
}).join('');
return { rows, count: visible.length };
}
const MEMBER_LABELS = ['Member', 'Level', 'Position', 'Activity', 'Xanax / day', 'Active 30d', 'Login streak', 'Growth', 'Est. stats', 'Flags', 'Actions'];
function renderMemberRows() {
const tbody = root.querySelector('[data-member-rows]');
const empty = root.querySelector('[data-empty]');
if (!tbody || !empty) return;
const { rows, count } = memberRowsMarkup();
tbody.innerHTML = rows;
tbody.querySelectorAll('tr').forEach((row) => [...row.children].forEach((cell, index) => {
cell.dataset.label = MEMBER_LABELS[index] || '';
}));
empty.classList.toggle('hidden', count > 0);
bindCopy(tbody);
bindMemberActions(tbody);
}
function bindMemberActions(scope) {
scope.querySelectorAll('[data-msg]').forEach((button) => button.addEventListener('click', () => {
const member = state.members.find((item) => Number(item.id) === Number(button.dataset.msg));
if (member) stageMessage(member, state.stats.get(Number(member.id)));
}));
scope.querySelectorAll('[data-kick]').forEach((button) => button.addEventListener('click', () => {
const member = state.members.find((item) => Number(item.id) === Number(button.dataset.kick));
if (member) stageKick(member, button);
}));
}
function membersMarkup() {
const faction = state.faction || {};
const online = state.members.filter((member) => statusKey(member) === 'online').length;
const idle = state.members.filter((member) => statusKey(member) === 'idle').length;
return `<div class="metrics">`
+ `<div class="metric"><div class="label">Faction</div><div class="value">${escapeHtml(faction.name || 'Your faction')}</div><div class="sub">${state.members.length} members</div></div>`
+ `<div class="metric good"><div class="label">Active now</div><div class="value">${online} online</div><div class="sub">${idle} idle</div></div>`
+ `<div class="metric warn" data-metric="xanax"><div class="label">Xanax used · 30d</div><div class="value">0</div><div class="sub">loading history</div></div>`
+ `<div class="metric" data-metric="growth"><div class="label">Median growth</div><div class="value">—</div><div class="sub">0-100 activity index</div></div>`
+ `<div class="metric bad" data-metric="flagged"><div class="label">Flagged low activity</div><div class="value">0</div><div class="sub">against your thresholds</div></div>`
+ `</div>`
+ `<div class="progress-wrap"><div class="progress-line"><span data-progress-label>Preparing member history…</span><span data-progress-count>0/${state.members.length}</span></div><div class="progress"><i data-progress-bar style="width:0%"></i></div></div>`
// The active filter and the search text have to be re-derived from
// state here: renderTab() rebuilds this markup from scratch, so
// hardcoding "active" on All would show the wrong button and an
// empty box while the rows stayed filtered.
+ `<div class="toolbar">`
+ [['all', 'All'], ['online', 'Online'], ['idle', 'Idle'], ['offline', 'Offline'], ['flagged', 'Flagged']]
.map(([id, label]) => `<button class="btn filter ${state.filter === id ? 'active' : ''}" data-filter="${id}">${label}</button>`)
.join('')
+ `<button class="btn" data-action="refresh-roster">Refresh roster</button>`
+ `<button class="btn" data-action="rebuild">Rebuild 30-day data</button>`
+ `<button class="btn" data-action="copy-flagged">Copy flagged IDs</button>`
+ `<input class="search" type="search" placeholder="Search member, ID, or position…" value="${escapeHtml(state.search)}">`
+ `</div>`
+ (state.isOwner ? '' : `<div class="notice">Kick and message tools are hidden: you are <b>${escapeHtml(state.me?.position || 'not in a faction')}</b> in ${escapeHtml(state.faction?.name || 'this faction')}, and they are Leader / Co-leader only.</div>`)
+ `<div class="table-wrap"><table class="cards"><thead><tr>${MEMBER_LABELS.map((label) => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead>`
+ `<tbody data-member-rows></tbody></table><div class="empty hidden" data-empty>No members match this filter.</div></div>`;
}
function updateSummary() {
const complete = [...state.stats.values()].filter((item) => item.available);
const loaded = state.stats.size;
const total = state.members.length;
const xanax = complete.reduce((sum, item) => sum + num(item.xanax_30d), 0);
const growths = complete.map((item) => num(item.growth)).sort((a, b) => a - b);
const median = growths.length ? growths[Math.floor(growths.length / 2)] : null;
const flagged = state.members.filter((member) => inactivityFlags(member, state.stats.get(Number(member.id))).length).length;
const setMetric = (name, value, sub) => {
const metric = root.querySelector(`[data-metric="${name}"]`);
if (!metric) return;
metric.querySelector('.value').textContent = value;
metric.querySelector('.sub').textContent = sub;
};
setMetric('xanax', xanax.toLocaleString(), `${complete.length} members with history`);
setMetric('growth', median === null ? '—' : String(median), 'median 0-100 activity index');
setMetric('flagged', String(flagged), 'against your thresholds');
const count = root.querySelector('[data-progress-count]');
const label = root.querySelector('[data-progress-label]');
const bar = root.querySelector('[data-progress-bar]');
if (count) count.textContent = `${loaded}/${total}`;
if (bar) bar.style.width = `${total ? (loaded / total) * 100 : 100}%`;
if (label) {
label.textContent = loaded < total
? 'Loading official 30-day snapshots — the dashboard can stay open while it progresses.'
: `History complete: ${complete.length}/${total} members available${loaded > complete.length ? `, ${loaded - complete.length} unavailable` : ''}.`;
}
}
function bindMembers() {
root.querySelectorAll('[data-filter]').forEach((button) => button.addEventListener('click', () => {
state.filter = button.dataset.filter;
root.querySelectorAll('[data-filter]').forEach((item) => item.classList.toggle('active', item === button));
renderMemberRows();
}));
root.querySelector('.search')?.addEventListener('input', (event) => {
state.search = event.target.value;
renderMemberRows();
});
root.querySelector('[data-action="refresh-roster"]')?.addEventListener('click', () => loadDashboard(false));
root.querySelector('[data-action="rebuild"]')?.addEventListener('click', async () => {
GM_deleteValue(HISTORY_STORE);
await loadDashboard(true);
});
root.querySelector('[data-action="copy-flagged"]')?.addEventListener('click', (event) => {
const ids = state.members
.filter((member) => inactivityFlags(member, state.stats.get(Number(member.id))).length)
.map((member) => member.id);
copyText(ids.join(','), event.currentTarget);
});
}
/* ------------------------------------------------------------- war tab */
function threatRows(members, ffMap) {
return members.map((member) => {
const ff = ffMap.get(Number(member.id));
return {
member,
ff,
estimate: num(ff?.bs_estimate),
fair: num(ff?.fair_fight),
};
}).sort((a, b) => (b.estimate - a.estimate)
|| (b.fair - a.fair)
|| (num(b.member.level) - num(a.member.level)));
}
const WAR_LABELS = ['Threat', 'Member', 'Level', 'Est. stats', 'Activity', 'Status', 'Actions'];
function warMarkup() {
const war = state.war;
const config = settings();
let body = '';
if (war.loading) {
body = '<div class="empty">Loading opponent roster…</div>';
} else if (war.error) {
body = `<div class="notice error">${escapeHtml(war.error)}</div>`;
} else if (!war.members.length) {
body = '<div class="empty">No opponent loaded. Use “Find current war” or enter a faction ID.</div>';
} else {
const ranked = threatRows(war.members, state.ff);
const online = war.members.filter((member) => statusKey(member) === 'online').length;
const hospital = war.members.filter((member) => String(member.status?.state || '').toLowerCase() === 'hospital').length;
const withEstimate = ranked.filter((row) => row.estimate);
const total = withEstimate.reduce((sum, row) => sum + row.estimate, 0);
const rows = ranked.map((row, index) => {
const member = row.member;
const activity = statusKey(member);
const hosp = String(member.status?.state || '').toLowerCase() === 'hospital';
return `<tr>`
+ `<td><span class="stat-main">#${index + 1}</span></td>`
+ `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${Number(member.id)}">${escapeHtml(member.name)}</a>`
+ copyBtn(member.name, 'name')
+ `<div class="muted">ID ${Number(member.id)}${copyBtn(String(member.id), 'id')}</div></td>`
+ `<td>${num(member.level)}</td>`
+ `<td>${ffCell(row.ff)}</td>`
+ `<td><span class="pill ${activity}">${escapeHtml(member.last_action?.status || 'Unknown')}</span>`
+ `<div class="muted">${escapeHtml(member.last_action?.relative || relativeTime(member.last_action?.timestamp))}</div></td>`
+ `<td><span class="pill ${hosp ? 'hosp' : 'offline'}">${escapeHtml(member.status?.state || 'Okay')}</span>`
+ `<div class="muted">${escapeHtml(member.status?.description || '')}</div></td>`
+ `<td><a class="mini" target="_blank" rel="noreferrer" href="https://www.torn.com/loader.php?sid=attack&user2ID=${Number(member.id)}">Attack</a></td>`
+ `</tr>`;
}).join('');
body = `<div class="metrics">`
+ `<div class="metric"><div class="label">Opponent</div><div class="value">${escapeHtml(war.faction?.name || 'Faction')}</div><div class="sub">${war.members.length} members</div></div>`
+ `<div class="metric good"><div class="label">Online now</div><div class="value">${online}</div><div class="sub">${hospital} in hospital</div></div>`
+ `<div class="metric warn"><div class="label">Estimated total stats</div><div class="value">${withEstimate.length ? shortNumber(total) : '—'}</div><div class="sub">${withEstimate.length}/${war.members.length} with FF data</div></div>`
+ `<div class="metric bad"><div class="label">Biggest threat</div><div class="value">${escapeHtml(ranked[0]?.member?.name || '—')}</div><div class="sub">${ranked[0]?.estimate ? shortNumber(ranked[0].estimate) : 'enable FFScouter'}</div></div>`
+ `<div class="metric"><div class="label">Respect</div><div class="value">${escapeHtml(String(war.faction?.respect ?? '—'))}</div><div class="sub">faction respect</div></div>`
+ `</div>`
+ (config.ffEnabled ? '' : '<div class="notice warnbox">FFScouter is off, so threat ranking falls back to level only. Turn it on in Settings for stat estimates.</div>')
+ `<div class="toolbar"><button class="btn" data-action="copy-war-ids">Copy all opponent IDs</button>`
+ `<button class="btn" data-action="copy-war-online">Copy online IDs</button></div>`
+ `<div class="table-wrap"><table class="cards"><thead><tr>${WAR_LABELS.map((l) => `<th>${escapeHtml(l)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
return `<div class="panel"><h3>Ranked war opponent</h3>`
+ `<div class="toolbar">`
+ `<button class="btn primary" data-action="find-war">Find current war</button>`
+ `<input class="text-input" data-war-id placeholder="…or enter opponent faction ID" value="${escapeHtml(war.opponentId)}" style="width:230px">`
+ `<button class="btn" data-action="load-war">Load faction</button>`
+ `</div>`
+ `<div class="muted">Threat order uses FFScouter battle-stat estimates when enabled, then level. Opponent rosters come from the public Torn faction endpoint.</div>`
+ `</div>${body}`;
}
function bindWar(content) {
content.querySelector('[data-action="find-war"]')?.addEventListener('click', findCurrentWar);
content.querySelector('[data-action="load-war"]')?.addEventListener('click', () => {
const input = content.querySelector('[data-war-id]');
loadOpponent(String(input?.value || '').trim());
});
content.querySelector('[data-war-id]')?.addEventListener('keydown', (event) => {
if (event.key === 'Enter') content.querySelector('[data-action="load-war"]').click();
});
content.querySelector('[data-action="copy-war-ids"]')?.addEventListener('click', (event) => {
copyText(state.war.members.map((m) => m.id).join(','), event.currentTarget);
});
content.querySelector('[data-action="copy-war-online"]')?.addEventListener('click', (event) => {
copyText(state.war.members.filter((m) => statusKey(m) === 'online').map((m) => m.id).join(','), event.currentTarget);
});
bindCopy(content);
}
async function findCurrentWar() {
state.war.loading = true;
state.war.error = '';
renderTab();
try {
const payload = await api('faction/rankedwars');
const wars = Array.isArray(payload?.rankedwars)
? payload.rankedwars
: Object.values(payload?.rankedwars || {});
// Exclude ourselves by BOTH id and name. faction/basic does not
// reliably expose an id on every key, and if it comes back
// undefined a naive "first faction with an id" pick happily
// returns our own faction as the opponent.
const myId = num(state.faction?.id);
const myName = String(state.faction?.name || '').toLowerCase();
let opponent = 0;
for (const war of wars) {
const factions = Array.isArray(war?.factions) ? war.factions : Object.values(war?.factions || {});
const other = factions.find((item) => {
const id = num(item?.id);
if (!id) return false;
if (myId && id === myId) return false;
if (myName && String(item?.name || '').toLowerCase() === myName) return false;
return true;
});
if (other) { opponent = num(other.id); break; }
}
if (!opponent) {
throw new Error(wars.length
? 'A war was found but the opponent could not be told apart from your own faction — enter the ID manually.'
: 'No ranked war is currently returned for this faction.');
}
await loadOpponent(String(opponent));
} catch (error) {
state.war.loading = false;
state.war.error = `Could not find a current ranked war: ${error?.message || error}`;
renderTab();
}
}
async function loadOpponent(factionId) {
const id = String(factionId || '').replace(/[^0-9]/g, '');
if (!id) {
state.war.error = 'Enter a numeric faction ID.';
return renderTab();
}
state.war.opponentId = id;
state.war.loading = true;
state.war.error = '';
renderTab();
try {
const payload = await apiV1(`faction/${id}`, ['basic']);
const members = Object.entries(payload?.members || {}).map(([memberId, data]) => ({
id: Number(memberId),
name: data?.name || `Player ${memberId}`,
level: num(data?.level),
position: data?.position || '',
last_action: data?.last_action || {},
status: data?.status || {},
days_in_faction: num(data?.days_in_faction),
}));
if (!members.length) throw new Error('That faction returned no members.');
state.war.faction = { id: num(payload?.ID) || Number(id), name: payload?.name || `Faction ${id}`, respect: payload?.respect };
state.war.members = members;
state.war.loading = false;
renderTab();
const ffMap = await ffLookup(members.map((m) => m.id));
ffMap.forEach((value, key) => state.ff.set(key, value));
renderTab();
} catch (error) {
state.war.loading = false;
state.war.members = [];
state.war.error = friendlyApiError(error, `Could not load faction ${id}`);
renderTab();
}
}
/* -------------------------------------------------------- recruits tab */
const RECRUIT_LABELS = ['Candidate', 'Level', 'Growth', 'Xanax / day', 'Active 30d', 'Streak', 'Est. stats', 'Faction', 'Activity', 'Mark'];
function recruitsMarkup() {
const recruits = state.recruits;
let body = '';
if (recruits.loading) {
body = '<div class="empty">Scanning candidates…</div>';
} else if (recruits.error) {
body = `<div class="notice error">${escapeHtml(recruits.error)}</div>`;
} else if (!recruits.rows.length) {
body = '<div class="empty">No candidates scanned yet.</div>';
} else {
const ranked = [...recruits.rows]
.filter((row) => !recruits.onlyMatches || recruitMatch(row) === true)
.sort((a, b) => num(b.growth) - num(a.growth));
const rows = ranked.map((row) => {
const activity = statusKey(row);
const match = recruitMatch(row);
return `<tr class="${match === true ? 'match' : ''}">`
+ `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${Number(row.id)}">${escapeHtml(row.name)}</a>`
+ copyBtn(row.name, 'name')
+ `<div class="muted">ID ${Number(row.id)}${copyBtn(String(row.id), 'id')}</div></td>`
+ `<td>${num(row.level)}</td>`
+ `<td>${row.available ? growthBadge(row.growth) : '<span class="muted">—</span>'}</td>`
+ `<td>${row.available ? `${num(row.xanax_per_day).toFixed(2)}/day` : `<span class="muted" title="${escapeHtml(row.error || '')}">n/a</span>`}</td>`
+ `<td>${row.available ? activeTime(row.active_seconds_30d) : '<span class="muted">n/a</span>'}</td>`
+ `<td>${row.available ? `${num(row.login_streak)}d` : '<span class="muted">n/a</span>'}</td>`
+ `<td>${ffCell(state.ff.get(Number(row.id)))}</td>`
+ `<td>${escapeHtml(row.faction_name || '—')}</td>`
+ `<td><span class="pill ${activity}">${escapeHtml(row.last_action?.status || 'Unknown')}</span>`
+ `<div class="muted">${escapeHtml(row.last_action?.relative || relativeTime(row.last_action?.timestamp))}</div></td>`
+ `<td>${match === null ? '<span class="muted">—</span>' : (match ? '<span class="pill you">MEETS</span>' : '<span class="muted">below</span>')}</td>`
+ `</tr>`;
}).join('');
body = `<div class="toolbar"><button class="btn" data-action="copy-recruit-ids">Copy candidate IDs</button>`
+ `<button class="btn" data-action="copy-top">Copy top 10 IDs</button>`
+ `<button class="btn" data-action="copy-matches">Copy matching IDs</button>`
+ `<button class="btn filter ${recruits.onlyMatches ? 'active' : ''}" data-action="toggle-matches">Only matches</button></div>`
+ `<div class="table-wrap"><table class="cards"><thead><tr>${RECRUIT_LABELS.map((l) => `<th>${escapeHtml(l)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
return `<div class="panel"><h3>Recruit scouting</h3>`
+ `<div class="field"><label>Candidates</label>`
+ `<textarea class="text-input" data-recruit-input placeholder="Paste player IDs — comma, space or newline separated Or scan an entire faction roster with f followed by ITS id, e.g. f${escapeHtml(String(state.faction?.id || '')) || '<faction id>'} for your own">${escapeHtml(recruits.input)}</textarea>`
+ `<div class="hint">Each candidate costs two Torn API calls for the 30-day window, so large rosters take a moment. Growth is the same 0-100 activity index used on the Members tab.</div></div>`
+ `<div class="inline-fields">`
+ `<div class="field"><label>Mark: min Xanax / day</label><input class="text-input" type="number" min="0" step="0.1" data-set="recruitMinXanax" value="${num(settings().recruitMinXanax)}"><div class="hint">0 = off. 30-day average.</div></div>`
+ `<div class="field"><label>Mark: min est. battle stats</label><input class="text-input" type="number" min="0" step="1000000" data-set="recruitMinStats" value="${num(settings().recruitMinStats)}"><div class="hint">0 = off. Needs FFScouter on.</div></div>`
+ `<div class="field"><label> </label><button class="btn primary" data-action="scan-recruits" style="width:100%">Scan candidates</button></div>`
+ `</div></div>${body}${browseMarkup()}`;
}
/* ---- candidate discovery -------------------------------------------
*
* There is no "random players" endpoint, so browsing uses the two public
* leaderboards that DO work (verified live 2026-08-26):
* torn/factionhof?cat=respect -> {id, name, members, position, rank,
* values:{respect}}
* torn/hof?cat=level|attacks|networth|awards
* -> {id, username, faction_id, level,
* last_action, rank_name, age_in_days}
* Both accept limit+offset, so "Load 50 more" walks down the ladder.
* Player rows carry faction_id, which is what makes "unfactioned only"
* possible - that is the genuinely recruitable pool.
*/
const BROWSE_CATS = {
factions: [['respect', 'Respect']],
players: [['level', 'Level'], ['attacks', 'Attacks'], ['networth', 'Networth'], ['awards', 'Awards']],
};
async function loadBrowse(more = false) {
const browse = state.browse;
browse.loading = true;
browse.error = '';
if (!more) { browse.rows = []; browse.offset = 0; }
renderTab();
try {
const path = browse.mode === 'factions' ? 'torn/factionhof' : 'torn/hof';
const payload = await api(path, { cat: browse.cat, limit: 50, offset: browse.offset });
const rows = browse.mode === 'factions' ? (payload?.factionhof || []) : (payload?.hof || []);
browse.rows = more ? [...browse.rows, ...rows] : rows;
browse.offset += 50;
} catch (error) {
browse.error = friendlyApiError(error, 'Could not load the leaderboard');
}
browse.loading = false;
renderTab();
}
/**
* "Good enough" mark for a candidate: minimum Xanax/day and/or minimum
* estimated battle stats. Returns null when both marks are off, so the
* table can tell "not configured" apart from "did not qualify".
*/
function recruitMatch(row) {
const config = settings();
const minXan = num(config.recruitMinXanax);
const minStats = num(config.recruitMinStats);
if (!minXan && !minStats) return null;
const xanOk = !minXan || (row.available && num(row.xanax_per_day) >= minXan);
const statsOk = !minStats || num(state.ff.get(Number(row.id))?.bs_estimate) >= minStats;
return Boolean(xanOk && statsOk);
}
function browseMarkup() {
const browse = state.browse;
const cats = BROWSE_CATS[browse.mode] || [];
let table = '';
if (browse.loading && !browse.rows.length) {
table = '<div class="empty">Loading…</div>';
} else if (browse.error) {
table = `<div class="notice error">${escapeHtml(browse.error)}</div>`;
} else if (!browse.rows.length) {
table = '<div class="empty">Pick a list and press Load.</div>';
} else if (browse.mode === 'factions') {
const rows = browse.rows.map((row) => `<tr>`
+ `<td>#${num(row.position)}</td>`
+ `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/factions.php?step=profile&ID=${num(row.id)}">${escapeHtml(row.name || '')}</a>`
+ copyBtn(String(num(row.id)), 'id') + `</td>`
+ `<td>${num(row.members)}</td>`
+ `<td>${escapeHtml(row.rank || '—')}</td>`
+ `<td>${num(row.values?.respect).toLocaleString()}</td>`
+ `<td><button class="mini warn" data-scan-faction="${num(row.id)}">Scan roster</button></td></tr>`).join('');
table = `<div class="table-wrap"><table class="cards"><thead><tr>`
+ ['#', 'Faction', 'Members', 'Rank', 'Respect', ''].map((l) => `<th>${l}</th>`).join('')
+ `</tr></thead><tbody>${rows}</tbody></table></div>`;
} else {
const visible = browse.freeOnly ? browse.rows.filter((row) => !num(row.faction_id)) : browse.rows;
const rows = visible.map((row) => `<tr>`
+ `<td>#${num(row.position)}</td>`
+ `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${num(row.id)}">${escapeHtml(row.username || '')}</a>`
+ copyBtn(String(num(row.id)), 'id') + `</td>`
+ `<td>${num(row.level)}</td>`
+ `<td>${num(row.faction_id) ? '<span class="muted">in a faction</span>' : '<span class="pill you">FREE</span>'}</td>`
+ `<td>${escapeHtml(relativeTime(row.last_action))}</td>`
+ `<td>${Math.round(num(row.age_in_days) / 36.5) / 10}y</td>`
+ `<td><button class="mini warn" data-add-player="${num(row.id)}">Add</button></td></tr>`).join('');
table = `<div class="muted" style="margin-bottom:8px">${visible.length} shown of ${browse.rows.length} loaded.</div>`
+ `<div class="table-wrap"><table class="cards"><thead><tr>`
+ ['#', 'Player', 'Level', 'Faction', 'Last action', 'Age', ''].map((l) => `<th>${l}</th>`).join('')
+ `</tr></thead><tbody>${rows}</tbody></table></div>`;
}
return `<div class="panel"><h3>Browse candidates</h3>`
+ `<div class="muted">No API lists random players, so this walks Torn's public leaderboards. Click a faction to scan its whole roster, or add players to the box above.</div>`
+ `<div class="toolbar" style="margin-top:10px">`
+ `<button class="btn filter ${browse.mode === 'factions' ? 'active' : ''}" data-browse-mode="factions">Factions</button>`
+ `<button class="btn filter ${browse.mode === 'players' ? 'active' : ''}" data-browse-mode="players">Players</button>`
+ `<select class="text-input" data-browse-cat>${cats.map(([id, label]) => (
`<option value="${id}" ${browse.cat === id ? 'selected' : ''}>${label}</option>`
)).join('')}</select>`
+ `<button class="btn primary" data-action="browse-load">Load</button>`
+ (browse.rows.length ? `<button class="btn" data-action="browse-more">Load 50 more</button>` : '')
+ (browse.mode === 'players'
? `<label class="muted" style="display:flex;align-items:center;gap:5px"><input type="checkbox" data-browse-free ${browse.freeOnly ? 'checked' : ''}> unfactioned only</label>`
+ `<button class="btn" data-action="add-all">Add all shown</button>`
: '')
+ `</div>${table}</div>`;
}
function addCandidates(ids) {
const existing = parseCandidates(state.recruits.input).ids;
const merged = [...new Set([...existing, ...ids.map((id) => num(id)).filter(Boolean)])];
state.recruits.input = merged.join(', ');
const box = root.querySelector('[data-recruit-input]');
if (box) box.value = state.recruits.input;
}
function bindBrowse(content) {
content.querySelectorAll('[data-browse-mode]').forEach((button) => button.addEventListener('click', () => {
state.browse.mode = button.dataset.browseMode;
state.browse.cat = (BROWSE_CATS[state.browse.mode] || [['respect']])[0][0];
state.browse.rows = [];
state.browse.offset = 0;
renderTab();
}));
content.querySelector('[data-browse-cat]')?.addEventListener('change', (event) => {
state.browse.cat = event.target.value;
state.browse.rows = [];
state.browse.offset = 0;
});
content.querySelector('[data-browse-free]')?.addEventListener('change', (event) => {
state.browse.freeOnly = event.target.checked;
renderTab();
});
content.querySelector('[data-action="browse-load"]')?.addEventListener('click', () => loadBrowse(false));
content.querySelector('[data-action="browse-more"]')?.addEventListener('click', () => loadBrowse(true));
content.querySelectorAll('[data-scan-faction]').forEach((button) => button.addEventListener('click', () => {
scanRecruits(`f${button.dataset.scanFaction}`);
}));
content.querySelectorAll('[data-add-player]').forEach((button) => button.addEventListener('click', () => {
addCandidates([button.dataset.addPlayer]);
button.textContent = 'Added';
button.classList.add('copied');
}));
content.querySelector('[data-action="add-all"]')?.addEventListener('click', (event) => {
const rows = state.browse.freeOnly
? state.browse.rows.filter((row) => !num(row.faction_id))
: state.browse.rows;
addCandidates(rows.map((row) => row.id));
event.currentTarget.textContent = `Added ${rows.length}`;
});
bindCopy(content);
}
function parseCandidates(text) {
const raw = String(text || '').trim();
const factionMatch = raw.match(/^f\s*([0-9]{1,10})$/i);
if (factionMatch) return { factionId: factionMatch[1], ids: [] };
const ids = [...new Set(raw.split(/[^0-9]+/).map((part) => Number(part)).filter((id) => Number.isFinite(id) && id > 0))];
return { factionId: '', ids };
}
async function scanRecruits(text) {
const parsed = parseCandidates(text);
state.recruits.input = text;
state.recruits.loading = true;
state.recruits.error = '';
state.recruits.rows = [];
renderTab();
try {
let candidates = [];
if (parsed.factionId) {
const payload = await apiV1(`faction/${parsed.factionId}`, ['basic']);
candidates = Object.entries(payload?.members || {}).map(([id, data]) => ({
id: Number(id),
name: data?.name || `Player ${id}`,
level: num(data?.level),
last_action: data?.last_action || {},
faction_name: payload?.name || '',
}));
} else {
candidates = parsed.ids.map((id) => ({ id, name: `Player ${id}`, level: 0, last_action: {}, faction_name: '' }));
}
if (!candidates.length) throw new Error('No candidate IDs found in that input.');
state.recruits.loading = false;
state.recruits.rows = candidates.map((row) => ({ ...row, available: false }));
renderTab();
for (const candidate of candidates) {
let row;
try {
if (!candidate.name || candidate.name.startsWith('Player ')) {
const profile = await apiV1(`user/${candidate.id}`, ['profile']);
candidate.name = profile?.name || candidate.name;
candidate.level = num(profile?.level, candidate.level);
candidate.last_action = profile?.last_action || candidate.last_action;
candidate.faction_name = profile?.faction?.faction_name || candidate.faction_name;
}
const history = await loadMemberHistory(candidate);
row = { ...candidate, ...history, available: true };
} catch (error) {
row = { ...candidate, available: false, error: String(error?.message || error) };
}
const index = state.recruits.rows.findIndex((item) => Number(item.id) === Number(candidate.id));
if (index >= 0) state.recruits.rows[index] = row;
// Throttled: renderTab() rebuilds the whole panel including the
// candidate textarea, so re-rendering once per candidate would
// both burn time on a big roster and yank the box out from
// under anyone still typing in it.
if (state.tab === 'recruits' && Date.now() - lastRecruitRender > 750) {
lastRecruitRender = Date.now();
renderTab();
}
}
if (state.tab === 'recruits') renderTab();
const ffMap = await ffLookup(candidates.map((c) => c.id));
ffMap.forEach((value, key) => state.ff.set(key, value));
if (state.tab === 'recruits') renderTab();
} catch (error) {
state.recruits.loading = false;
state.recruits.error = friendlyApiError(error, 'Candidate scan failed');
renderTab();
}
}
function bindRecruits(content) {
content.querySelector('[data-action="scan-recruits"]')?.addEventListener('click', () => {
scanRecruits(String(content.querySelector('[data-recruit-input]')?.value || ''));
});
content.querySelector('[data-recruit-input]')?.addEventListener('input', (event) => {
state.recruits.input = event.target.value;
});
content.querySelector('[data-action="copy-recruit-ids"]')?.addEventListener('click', (event) => {
copyText(state.recruits.rows.map((row) => row.id).join(','), event.currentTarget);
});
content.querySelector('[data-action="copy-top"]')?.addEventListener('click', (event) => {
const top = [...state.recruits.rows].sort((a, b) => num(b.growth) - num(a.growth)).slice(0, 10);
copyText(top.map((row) => row.id).join(','), event.currentTarget);
});
content.querySelector('[data-action="copy-matches"]')?.addEventListener('click', (event) => {
const ids = state.recruits.rows.filter((row) => recruitMatch(row) === true).map((row) => row.id);
copyText(ids.join(','), event.currentTarget);
});
content.querySelector('[data-action="toggle-matches"]')?.addEventListener('click', () => {
state.recruits.onlyMatches = !state.recruits.onlyMatches;
renderTab();
});
// The two Mark thresholds live on this tab but are saved through the
// same [data-set] settings path the Settings tab uses.
content.querySelectorAll('[data-set]').forEach((input) => {
input.addEventListener('change', () => {
saveSettings({ [input.dataset.set]: input.type === 'number' ? num(input.value) : input.value });
renderTab();
});
});
bindBrowse(content);
bindCopy(content);
}
/* -------------------------------------------------------------- oc tab */
const OC_LABELS = ['Crime', 'Status', 'Ready', 'Slots', 'Rewards'];
/**
* OC 2.0 slot readers - SCHEMA VERIFIED 2026-08-26 against a live
* /v2/user/organizedcrime payload, not guessed:
*
* position "Techie"
* position_info { id: "P2", label: "Techie", number: 1 }
* item_requirement { id: 856, is_reusable: false, is_available: true }
* user { id, joined_at, progress, outcome, item_outcome }
* checkpoint_pass_rate 65
*
* Two earlier guesses were WRONG and are corrected here: the display name
* is `position_info.label` (not .name), and there is NO top-level
* `user_id` - only `user.id`, which is null for an empty seat. The old
* fallbacks are kept because the FACTION board endpoint has still never
* been seen, and it may differ.
*/
function slotPosition(slot) {
return slot?.position_info?.label || slot?.position_info?.name || slot?.position || 'Slot';
}
function slotUserId(slot) {
return num(slot?.user?.id) || num(slot?.user_id);
}
function slotProgress(slot) {
const value = Number(slot?.user?.progress);
return Number.isFinite(value) ? value : null;
}
function slotCpr(slot) {
const value = slot?.checkpoint_pass_rate ?? slot?.success_chance
?? slot?.position_info?.checkpoint_pass_rate ?? slot?.position_info?.success_chance;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
/** { id, name, reusable, available } or null. */
function slotItem(slot) {
const req = slot?.item_requirement;
if (!req) return null;
if (typeof req === 'string') return { id: 0, name: req, reusable: false, available: null };
const id = num(req.id);
return {
id,
name: req.name || req.item_name || state.itemNames.get(id) || (id ? `Item ${id}` : 'Item'),
reusable: Boolean(req.is_reusable),
available: typeof req.is_available === 'boolean' ? req.is_available : null,
};
}
function itemCache() {
const cache = safeParse(GM_getValue(ITEM_STORE, '{}')) || {};
return cache && typeof cache === 'object' ? cache : {};
}
/**
* item_requirement carries an id but no name, so names are looked up once
* and cached forever - Torn item names do not change.
*/
async function resolveItemNames(ids) {
const cache = itemCache();
let added = false;
for (const raw of ids) {
const id = num(raw);
if (!id) continue;
if (cache[id]) { state.itemNames.set(id, cache[id]); continue; }
try {
const payload = await apiV1(`torn/${id}`, ['items']);
const name = payload?.items?.[String(id)]?.name;
if (name) { cache[id] = name; state.itemNames.set(id, name); added = true; }
} catch (_) { /* a missing name is cosmetic */ }
}
if (added) GM_setValue(ITEM_STORE, JSON.stringify(cache));
}
// Torn has no documented per-crime deep link, so this goes to the crimes
// tab - the same place the old panel linked to. The crime name is copied
// alongside so it can be found in Torn's list.
const OC_URL = 'https://www.torn.com/factions.php?step=your&type=1#/tab=crimes';
function itemChip(item) {
if (!item) return '';
const label = escapeHtml(item.name);
if (item.available === true) return `<span class="have">has ${label}</span>`;
if (item.available === false) return `<span class="need">needs ${label}</span>`;
return `<span class="muted">needs ${label}</span>`;
}
function slotLine(slot, names, meId) {
const position = slotPosition(slot);
const cpr = slotCpr(slot);
const progress = slotProgress(slot);
const item = slotItem(slot);
const uid = slotUserId(slot);
const isMe = meId && uid === meId;
const meta = [
cpr === null ? '' : `<span class="cpr">${cpr}% CPR</span>`,
progress === null ? '' : `<span class="muted">${progress}% done</span>`,
itemChip(item),
].filter(Boolean).join(' ');
if (!uid) {
return `<div class="slot open-slot"><span class="muted">${escapeHtml(position)}:</span> `
+ `<span class="pill open">OPEN</span> ${meta} `
+ `<a class="mini" target="_blank" rel="noreferrer" href="${OC_URL}">Join in Torn</a>`
+ copyBtn(position, 'role') + `</div>`;
}
const name = names[uid] || String(uid);
return `<div class="slot${isMe ? ' me-slot' : ''}"><span class="muted">${escapeHtml(position)}:</span> `
+ `<a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${uid}">${escapeHtml(name)}</a>`
+ (isMe ? ' <span class="pill you">YOU</span>' : '') + ` ${meta}`
+ copyBtn(name, 'name') + copyBtn(String(uid), 'id') + `</div>`;
}
/**
* Your own crime. This is the half of the tab that works for EVERY member,
* because /v2/user/organizedcrime is your own data and needs no faction
* API Access permission - unlike the faction-wide board below it.
*/
function myOcMarkup() {
if (state.oc.mineError) return `<div class="notice error">${escapeHtml(state.oc.mineError)}</div>`;
const mine = state.oc.mine;
if (!mine) {
return `<div class="panel"><h3>Your organized crime</h3>`
+ `<div class="muted">You are not in an organized crime right now.</div>`
+ `<div class="toolbar" style="margin-top:10px">`
+ `<a class="btn primary" target="_blank" rel="noreferrer" href="${OC_URL}">Find a crime to join</a></div></div>`;
}
const meId = num(state.me?.player_id);
const names = Object.fromEntries(state.members.map((member) => [Number(member.id), member.name]));
const mySlot = (mine.slots || []).find((slot) => slotUserId(slot) === meId);
const myItem = slotItem(mySlot);
const ordered = [...(mine.slots || [])].sort((a, b) => {
const openA = slotUserId(a) ? 1 : 0;
const openB = slotUserId(b) ? 1 : 0;
return openA - openB || (slotCpr(b) || 0) - (slotCpr(a) || 0);
});
const blocked = myItem && myItem.available === false;
return `<div class="metrics">`
+ `<div class="metric"><div class="label">Your crime</div><div class="value">${escapeHtml(mine.name || 'Crime')}</div><div class="sub">level ${num(mine.difficulty)} · ${escapeHtml(mine.status || '')}</div></div>`
+ `<div class="metric good"><div class="label">Your role</div><div class="value">${escapeHtml(mySlot ? slotPosition(mySlot) : '—')}</div><div class="sub">${mySlot && slotCpr(mySlot) !== null ? `${slotCpr(mySlot)}% pass rate` : 'pass rate unknown'}</div></div>`
+ `<div class="metric"><div class="label">Your progress</div><div class="value">${mySlot && slotProgress(mySlot) !== null ? `${slotProgress(mySlot)}%` : '—'}</div><div class="sub">recruit progress</div></div>`
+ `<div class="metric ${blocked ? 'bad' : 'good'}"><div class="label">Your item</div><div class="value">${myItem ? escapeHtml(myItem.name) : 'none'}</div><div class="sub">${myItem ? (myItem.available === false ? 'YOU DO NOT HAVE IT' : myItem.available === true ? `in hand${myItem.reusable ? ' · reusable' : ''}` : 'availability unknown') : 'no item needed'}</div></div>`
+ `<div class="metric warn"><div class="label">Ready</div><div class="value">${escapeHtml(untilText(mine.ready_at))}</div><div class="sub">${mine.ready_at ? escapeHtml(new Date(num(mine.ready_at) * 1000).toLocaleString()) : ''}</div></div>`
+ `</div>`
+ (blocked ? `<div class="notice error">Your role needs <b>${escapeHtml(myItem.name)}</b> and Torn reports you do not have it. The crime can fail on your checkpoint without it.</div>` : '')
+ `<div class="panel"><h3>Your crew</h3>${ordered.map((slot) => slotLine(slot, names, meId)).join('')}`
+ `<div class="toolbar" style="margin-top:10px"><a class="btn" target="_blank" rel="noreferrer" href="${OC_URL}">Open in Torn</a>`
+ `<button class="btn" data-action="reload-oc">Reload</button></div></div>`;
}
function ocMarkup() {
const oc = state.oc;
if (oc.loading) return '<div class="empty">Loading organized crimes…</div>';
// Your own crime always renders. The faction-wide board is a bonus that
// needs API Access, so its failure is a soft note under your crime -
// not a red wall where the whole tab used to be.
let board = '';
if (oc.error) {
board = `<div class="panel"><h3>Faction crime board</h3>`
+ `<div class="notice warnbox">${escapeHtml(oc.error)}</div></div>`;
} else if (!oc.crimes.length) {
board = `<div class="panel"><h3>Faction crime board</h3>`
+ `<div class="muted">No organized crimes in progress.</div></div>`;
} else {
const names = Object.fromEntries(state.members.map((member) => [Number(member.id), member.name]));
const meId = num(state.me?.player_id);
const recruiting = oc.crimes.filter((crime) => String(crime.status).toLowerCase() === 'recruiting').length;
const openSlots = oc.crimes.reduce((sum, crime) => sum + (crime.slots || []).filter((slot) => !slotUserId(slot)).length, 0);
const ready = oc.crimes.filter((crime) => num(crime.ready_at) <= Date.now() / 1000 && String(crime.status).toLowerCase() === 'planning').length;
const ordered = [...oc.crimes].sort((a, b) => {
const openA = (a.slots || []).filter((slot) => !slotUserId(slot)).length;
const openB = (b.slots || []).filter((slot) => !slotUserId(slot)).length;
return (openB ? 1 : 0) - (openA ? 1 : 0) || num(a.ready_at) - num(b.ready_at);
});
const rows = ordered.map((crime) => {
const sorted = [...(crime.slots || [])].sort((a, b) => {
const openA = slotUserId(a) ? 1 : 0;
const openB = slotUserId(b) ? 1 : 0;
return openA - openB || (slotCpr(b) || 0) - (slotCpr(a) || 0);
});
const assigned = (crime.slots || []).filter((slot) => slotUserId(slot))
.map((slot) => names[slotUserId(slot)] || slotUserId(slot));
return `<tr>`
+ `<td><span class="stat-main">${escapeHtml(crime.name || `Crime ${crime.id}`)}</span>`
+ (assigned.length ? copyBtn(assigned.join(', '), 'all names') : '')
+ `<div class="muted">Level ${num(crime.difficulty)}</div></td>`
+ `<td><span class="pill ${String(crime.status).toLowerCase() === 'recruiting' ? 'idle' : 'offline'}">${escapeHtml(crime.status || '')}</span></td>`
+ `<td><span class="stat-main">${escapeHtml(untilText(crime.ready_at))}</span></td>`
+ `<td>${sorted.map((slot) => slotLine(slot, names, meId)).join('') || '<span class="muted">—</span>'}</td>`
+ `<td>${crime.rewards?.money ? `$${num(crime.rewards.money).toLocaleString()}` : '<span class="muted">—</span>'}</td>`
+ `</tr>`;
}).join('');
board = `<div class="metrics">`
+ `<div class="metric"><div class="label">Crimes</div><div class="value">${oc.crimes.length}</div><div class="sub">in progress</div></div>`
+ `<div class="metric warn"><div class="label">Recruiting</div><div class="value">${recruiting}</div><div class="sub">still filling</div></div>`
+ `<div class="metric bad"><div class="label">Open slots</div><div class="value">${openSlots}</div><div class="sub">need a member</div></div>`
+ `<div class="metric good"><div class="label">Ready</div><div class="value">${ready}</div><div class="sub">planning complete</div></div>`
+ `<div class="metric"><div class="label">Members</div><div class="value">${state.members.length}</div><div class="sub">on the roster</div></div>`
+ `</div>`
+ `<div class="toolbar"><button class="btn" data-action="reload-oc">Reload crimes</button>`
+ `<a class="btn" target="_blank" rel="noreferrer" href="${OC_URL}">Open faction crimes</a>`
+ `<button class="btn" data-action="copy-open">Copy open roles</button></div>`
+ `<div class="notice">Open seats first, then by pass rate. <b>Joining happens on Torn</b> — the API has no join endpoint, and auto-clicking one would be automation.</div>`
+ `<div class="table-wrap"><table class="cards"><thead><tr>${OC_LABELS.map((l) => `<th>${escapeHtml(l)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
return myOcMarkup() + board;
}
async function loadCrimes() {
state.oc.loading = true;
state.oc.error = '';
state.oc.mineError = '';
renderTab();
// YOUR OWN crime needs no faction permission - verified live. This is
// the part that works for every member regardless of rank, so it is
// fetched first and independently of the faction board.
try {
const payload = await api('user/organizedcrime');
state.oc.mine = payload?.organizedCrime || null;
} catch (error) {
state.oc.mine = null;
state.oc.mineError = friendlyApiError(error, 'Could not load your organized crime');
}
// The faction-WIDE board additionally needs the API Access permission,
// so this half is allowed to fail without taking the tab down with it.
// No `cat` is sent: the valid enum is unconfirmed and omitting it is
// the safer default.
try {
const payload = await api('faction/crimes');
state.oc.crimes = Array.isArray(payload?.crimes) ? payload.crimes : [];
} catch (error) {
state.oc.crimes = [];
state.oc.error = friendlyApiError(error, 'The faction-wide crime board is unavailable');
}
const ids = [];
const collect = (slots) => (slots || []).forEach((slot) => {
const id = num(slot?.item_requirement?.id);
if (id) ids.push(id);
});
collect(state.oc.mine?.slots);
state.oc.crimes.forEach((crime) => collect(crime.slots));
await resolveItemNames([...new Set(ids)]);
state.oc.loading = false;
state.oc.loaded = true;
renderTab();
}
function bindOc(content) {
content.querySelectorAll('[data-action="reload-oc"]').forEach((button) => button.addEventListener('click', () => {
state.oc.loaded = false;
loadCrimes();
}));
content.querySelector('[data-action="copy-open"]')?.addEventListener('click', (event) => {
const open = [];
for (const crime of state.oc.crimes) {
for (const slot of crime.slots || []) {
if (!slotUserId(slot)) open.push(`${crime.name || 'Crime'} - ${slotPosition(slot)}`);
}
}
copyText(open.join('\n'), event.currentTarget);
});
bindCopy(content);
}
/* --------------------------------------------------------- settings tab */
function settingsMarkup() {
const config = settings();
return `<div class="panel"><h3>Low activity thresholds</h3>`
+ `<div class="muted">A member is flagged when any of these is true. Flagged rows are highlighted on the Members tab.</div>`
+ `<div class="inline-fields" style="margin-top:12px">`
+ `<div class="field"><label>Days since last action</label><input class="text-input" type="number" min="1" max="365" data-set="inactiveDays" value="${num(config.inactiveDays)}"><div class="hint">Flag at or above this.</div></div>`
+ `<div class="field"><label>Xanax per day below</label><input class="text-input" type="number" min="0" step="0.1" data-set="lowXanaxPerDay" value="${num(config.lowXanaxPerDay)}"><div class="hint">30-day average.</div></div>`
+ `<div class="field"><label>Active hours / 30d below</label><input class="text-input" type="number" min="0" step="0.5" data-set="minActiveHours30d" value="${num(config.minActiveHours30d)}"><div class="hint">Total, not per day.</div></div>`
+ `</div></div>`
+ `<div class="panel"><h3>FFScouter</h3>`
+ `<div class="muted">FFScouter (ffscouter.com) is a third party. Turning this on sends the player IDs you scout, and your Torn API key, to that site. It is off by default.</div>`
+ `<div class="field" style="margin-top:12px"><label><input type="checkbox" data-set-check="ffEnabled" ${config.ffEnabled ? 'checked' : ''}> Enable FFScouter battle-stat estimates</label>`
+ `<div class="hint">Without it, war threat ranking falls back to level only.</div></div>`
+ `<button class="btn" data-action="clear-ff">Clear cached FFScouter data</button></div>`
+ `<div class="panel"><h3>Inactivity message template</h3>`
+ `<div class="muted">Used by the Message button on flagged members. Placeholders: {name} {id} {faction} {position} {last_action} {days_inactive} {xanax_per_day} {active_30d} {streak}</div>`
+ `<div class="field" style="margin-top:12px"><textarea class="text-input" data-set="messageTemplate">${escapeHtml(config.messageTemplate)}</textarea></div>`
+ `<div class="notice">The Torn API cannot send mail. This fills Torn's own compose box and leaves the Send button to you.</div>`
+ `<button class="btn" data-action="reset-template">Reset to default</button></div>`
+ `<div class="panel"><h3>Your access</h3>`
+ `<div class="muted">Signed in as ${escapeHtml(state.me?.name || 'unknown')} (ID ${num(state.me?.player_id)}) · position ${escapeHtml(state.me?.position || 'unknown')}.`
+ ` Kick and message tools are ${state.isOwner ? 'ENABLED' : 'hidden — Leader or Co-leader only'}.</div></div>`;
}
function bindSettings(content) {
content.querySelectorAll('[data-set]').forEach((input) => {
input.addEventListener('change', () => {
const key = input.dataset.set;
const value = input.type === 'number' ? num(input.value) : input.value;
saveSettings({ [key]: value });
if (state.tab === 'members') renderTab();
});
});
content.querySelectorAll('[data-set-check]').forEach((input) => {
input.addEventListener('change', () => {
saveSettings({ [input.dataset.setCheck]: input.checked });
renderTab();
});
});
content.querySelector('[data-action="clear-ff"]')?.addEventListener('click', () => {
GM_deleteValue(FF_STORE);
state.ff = new Map();
renderTab();
});
content.querySelector('[data-action="reset-template"]')?.addEventListener('click', () => {
saveSettings({ messageTemplate: DEFAULT_SETTINGS.messageTemplate });
renderTab();
});
}
/* ---------------------------------------------------------- tab router */
function renderTab() {
let markup = '';
if (state.tab === 'members') markup = membersMarkup();
else if (state.tab === 'war') markup = warMarkup();
else if (state.tab === 'recruits') markup = recruitsMarkup();
else if (state.tab === 'oc') markup = ocMarkup();
else markup = settingsMarkup();
const content = shell(markup, true);
// The Crimes tab had no loader of its own - it rendered state.oc, which
// nothing ever filled, so it always read "No organized crimes returned".
if (state.tab === 'oc' && !state.oc.loaded && !state.oc.loading) {
loadCrimes();
}
if (state.tab === 'members') {
bindMembers();
renderMemberRows();
updateSummary();
} else if (state.tab === 'war') bindWar(content);
else if (state.tab === 'recruits') bindRecruits(content);
else if (state.tab === 'oc') bindOc(content);
else bindSettings(content);
return content;
}
/* ------------------------------------------------------------- loading */
async function scanHistory(force, token) {
for (const member of state.members) {
if (token !== state.scanToken) return;
let row = force ? null : cachedHistoryRow(member.id);
if (!row) {
try {
row = await loadMemberHistory(member);
saveHistoryRow(row);
recordTrend(row);
} catch (error) {
row = { id: Number(member.id), available: false, error: String(error?.message || error) };
}
}
state.stats.set(Number(member.id), row);
if (state.tab === 'members') {
renderMemberRows();
updateSummary();
}
}
if (token === state.scanToken) state.loading = false;
}
async function resolveIdentity() {
try {
const profile = await apiV1('user', ['profile']);
state.me = {
player_id: num(profile?.player_id),
name: profile?.name || '',
position: profile?.faction?.position || '',
};
} catch (_) {
state.me = null;
}
// faction/basic returns leader_id and co_leader_id, which is far more
// reliable than string-matching a position name (factions rename ranks
// freely). The position string stays as a fallback for the case where
// faction/basic did not include the ids.
const myId = num(state.me?.player_id);
const byId = Boolean(myId) && (myId === num(state.faction?.leader_id) || myId === num(state.faction?.co_leader_id));
const position = String(state.me?.position || '').toLowerCase();
const byPosition = ['leader', 'co-leader', 'coleader'].includes(position);
state.isOwner = byId || byPosition;
}
async function loadDashboard(forceHistory = false) {
const token = ++state.scanToken;
state.loading = true;
state.stats = new Map();
shell('<div class="setup"><h2>Loading faction roster…</h2><div class="notice">Connecting to the official Torn API.</div></div>');
try {
const basicPayload = await api('faction/basic');
const membersPayload = await api('faction/members', { striptags: 'true' });
if (token !== state.scanToken) return;
state.faction = basicPayload?.basic || {};
state.members = Array.isArray(membersPayload?.members) ? membersPayload.members : [];
const order = { online: 0, idle: 1, offline: 2, other: 3 };
state.members.sort((a, b) => (order[statusKey(a)] - order[statusKey(b)])
|| num(b.last_action?.timestamp) - num(a.last_action?.timestamp)
|| String(a.name).localeCompare(String(b.name)));
if (!state.members.length) throw new Error('No faction members were returned for this API key.');
await resolveIdentity();
if (token !== state.scanToken) return;
state.tab = 'members';
renderTab();
await scanHistory(forceHistory, token);
if (settings().ffEnabled) {
const ffMap = await ffLookup(state.members.map((m) => m.id));
ffMap.forEach((value, key) => state.ff.set(key, value));
if (state.tab === 'members') renderMemberRows();
}
} catch (error) {
if (token !== state.scanToken) return;
state.loading = false;
renderSetup(`Error: ${error?.message || error}`);
}
}
function openDashboard() {
if (!apiKey()) renderSetup();
else if (state.members.length) renderTab();
else loadDashboard(false);
}
/* -------------------------------------------------- mail draft handler */
/**
* Runs on Torn's own compose page. Picks up a draft staged by the Message
* button and types it into the box. It NEVER submits: the user reviews and
* presses Send. The draft is cleared as soon as it is used, and expires by
* itself after 10 minutes so an abandoned one cannot resurface later.
*/
function setNativeValue(element, value) {
const prototype = element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (setter) setter.call(element, value);
else element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}
function installMailFiller() {
let attempts = 0;
const timer = setInterval(() => {
attempts += 1;
if (attempts > 40) return clearInterval(timer);
const draft = safeParse(GM_getValue(MAIL_STORE, 'null'));
if (!draft || Date.now() - num(draft.staged_at) > 10 * 60 * 1000) {
if (draft) GM_deleteValue(MAIL_STORE);
return;
}
if (!/messages\.php/i.test(location.pathname) || !/compose/i.test(location.hash)) return;
if (!location.hash.includes(String(draft.id))) return;
const box = document.querySelector('textarea[name="message"], .message-box textarea, form textarea');
if (!box || box.dataset.tfaFilled) return;
box.dataset.tfaFilled = '1';
setNativeValue(box, draft.body);
GM_deleteValue(MAIL_STORE);
clearInterval(timer);
const banner = document.createElement('div');
banner.textContent = `Draft filled by Faction Command for ${draft.name}. Review it, then press Send yourself.`;
banner.style.cssText = 'margin:8px 0;padding:9px 12px;border:1px solid #5380dc;border-radius:8px;'
+ 'background:#151c29;color:#bfcbe1;font:12px/1.4 "Segoe UI",sans-serif;';
box.parentElement?.insertBefore(banner, box);
}, 500);
}
/* ---------------------------------------------------------------- boot */
if (typeof document === 'undefined') return;
installMailFiller();
mountLauncher();
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Open Faction Command', openDashboard);
GM_registerMenuCommand('Reset API key and cached data', () => {
[API_KEY_STORE, HISTORY_STORE, TREND_STORE, FF_STORE, MAIL_STORE].forEach(GM_deleteValue);
renderSetup('Saved API key and cached data removed.');
});
}
}());