// ==UserScript==
// @name Torn Faction Organized Crime Metrics
// @namespace https://torn.com/
// @version 1.0.1
// @description Uses Torn API to list Organized Crime metrics for your faction.
// @author Canixe [3753120]
// @match https://www.torn.com/factions.php*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.registerMenuCommand
// @grant GM_registerMenuCommand
// @connect api.torn.com
// ==/UserScript==
(() => {
"use strict";
////////////////////////////////////////////////////////////////////////////
// CONSTANTS
////////////////////////////////////////////////////////////////////////////
const SECTION_ID = "tf-oc-crime-metrics";
const TITLE_TEXT = "Faction OC Crime Metrics";
const API_COMMENT = "TF-OC-Crime-Metrics";
const REQUIRED_ACCESS = "Limited Access";
const CRIMES_API = "https://api.torn.com/v2/faction/crimes";
const NEWS_API = "https://api.torn.com/v2/faction/news";
const ITEMS_API = "https://api.torn.com/v2/torn/items";
const API_PAGE_CAP = 100;
const FIG = "\u2007";
const ITEM_INFO_CACHE_KEY = `${SECTION_ID}:items_cache_v1.1`;
const ITEM_INFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
let ITEM_INFO = null;
const ICONS = {
play: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 3 14 9-14 9z"/></svg>',
stop: '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
};
const CLS = { caretFill: "grayFill___tkuer" };
const OPEN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="16" viewBox="0 0 11 16" class="${CLS.caretFill}"><path d="M1302,21l-5,5V16Z" transform="translate(-1294 -13)"/></svg>`;
const COLLAPSE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="11" viewBox="0 0 16 11" class="${CLS.caretFill}"><path d="M1302,21l-5,5V16Z" transform="translate(29 -1294) rotate(90)"/></svg>`;
////////////////////////////////////////////////////////////////////////////
// STYLES
////////////////////////////////////////////////////////////////////////////
const style = `
#${SECTION_ID}{ margin-top:14px; }
#${SECTION_ID} #${SECTION_ID}-content{ padding:5px; background:var(--default-bg-panel-color); border-radius:0 0 6px 6px; }
/* Pills (API key + dates + status + controls) */
#${SECTION_ID} .pill{ display:inline-block; border:1px solid var(--default-panel-divider-outer-side-color); border-radius:999px; padding:2px 8px; font-size:11px; background:var(--default-bg-panel-active-color); }
#${SECTION_ID} .pill a{ color:var(--default-blue-color); text-decoration:underline; }
#${SECTION_ID} .pills-row{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; align-items:center; }
#${SECTION_ID} .pill-date input{ border:none; background:transparent; color:inherit; font:inherit; padding:0 2px; outline:none; }
#${SECTION_ID} .pills-right{ margin-left:auto; display:flex; gap:6px; }
#${SECTION_ID} .btn-icon{ width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
#${SECTION_ID} .btn-icon .tf-icon{ width:16px; height:16px; }
#${SECTION_ID} .status-err{ color:#b00020; font-size:12px; margin-left:6px; display:none; }
#${SECTION_ID} .header___f_BFs{ display:flex; align-items:center; padding:0 8px; }
#${SECTION_ID} .icons___VmEI4{ margin-left:auto; display:flex; align-items:center; gap:6px; }
#${SECTION_ID} .icons___VmEI4 .button___MO5cW{ background:transparent; border:0; padding:6px; line-height:0; cursor:pointer; }
#${SECTION_ID} .icons___VmEI4 .${CLS.caretFill}{ fill:#cfd6de; }
#${SECTION_ID} .icons___VmEI4 .button___MO5cW:hover .${CLS.caretFill}{ fill:#ffffff; }
#${SECTION_ID} .kpis{ display:grid; gap:6px; grid-template-columns:repeat(2, minmax(0,1fr)); }
@media (min-width:740px){ #${SECTION_ID} .kpis{ grid-template-columns:repeat(3, minmax(0,1fr)); } }
@media (min-width:980px){ #${SECTION_ID} .kpis{ grid-template-columns:repeat(5, minmax(0,1fr)); } }
#${SECTION_ID} .kpi{ background:var(--default-bg-panel-active-color); border:1px solid var(--default-panel-divider-outer-side-color); border-radius:6px; padding:6px 8px; min-width:0; box-sizing:border-box; }
#${SECTION_ID} .kpi .label{ font-size:11px; opacity:.85; color:var(--default-color); }
#${SECTION_ID} .kpi .value{ font-weight:700; font-size:14px; color:var(--default-color); }
#${SECTION_ID} .kpi .sub{ font-size:11px; opacity:.75; color:var(--default-color); text-align:right; display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#${SECTION_ID} .grid{ display:grid; gap:8px; grid-template-columns:1fr; }
#${SECTION_ID} .card{ border:1px solid var(--default-panel-divider-outer-side-color); border-radius:6px; background:var(--default-bg-panel-color); padding:8px; }
#${SECTION_ID} .card h4{ margin:0 0 6px; font-weight:700; font-size:13px; display:flex; align-items:center; justify-content:space-between; }
#${SECTION_ID} .card--oc{ grid-column:1/-1; margin-top:8px; }
#${SECTION_ID} table{ width:100%; border-collapse:collapse; color:var(--default-color); }
#${SECTION_ID} .table--compact th, #${SECTION_ID} .table--compact td{ padding:6px 12px; font-size:12px; border-bottom:1px solid var(--default-panel-divider-outer-side-color); vertical-align:middle; }
#${SECTION_ID} table thead th{ text-align:center !important; }
#${SECTION_ID} td:first-child{ text-align:left; }
#${SECTION_ID} .w-min{ width:1%; white-space:nowrap; text-align:right; }
#${SECTION_ID} td.center{ text-align:center; }
#${SECTION_ID}, #${SECTION_ID} .card, #${SECTION_ID} table td, #${SECTION_ID} table th, #${SECTION_ID} .pill, #${SECTION_ID} .items-list li{ color:var(--default-color); }
#${SECTION_ID} .good{ color:var(--default-green-color,#66bb6a); }
#${SECTION_ID} .bad { color:var(--default-red-color,#e57373); }
#${SECTION_ID} .warn{ color:var(--default-yellow-color,#e0c200); }
#${SECTION_ID} .oc-name{ white-space:nowrap; }
#${SECTION_ID} .items-col{ white-space:normal; }
#${SECTION_ID} .items-list{ margin:0; padding-left:18px; }
#${SECTION_ID} .items-list li{ margin:0; padding:0; }
#${SECTION_ID} .mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
#${SECTION_ID} .icons___VmEI4 button,
#${SECTION_ID} #${SECTION_ID}-run,
#${SECTION_ID} #${SECTION_ID}-stop{ color:var(--default-color); }
#${SECTION_ID} .icons___VmEI4 button svg,
#${SECTION_ID} #${SECTION_ID}-run svg,
#${SECTION_ID} #${SECTION_ID}-stop svg{ stroke:currentColor; fill:none; }
#${SECTION_ID} #${SECTION_ID}-run[disabled],
#${SECTION_ID} #${SECTION_ID}-stop[disabled]{ opacity:.55; }
`;
////////////////////////////////////////////////////////////////////////////
// UTILS
////////////////////////////////////////////////////////////////////////////
const delay = (ms) => new Promise(r => setTimeout(r, ms));
const escapeHtml = (s) => String(s)
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
.replace(/"/g,""").replace(/'/g,"'");
const fmtMoneySpace = (n) => String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
const pad2 = (n) => String(n).padStart(2, FIG);
const padPct = (r) => r.toFixed(1).padStart(5, FIG) + "%";
async function getSetting(key, def=""){
try{
if (typeof GM !== "undefined" && GM.getValue) return await GM.getValue(key, def);
if (typeof GM_getValue !== "undefined"){ const v = GM_getValue(key); return v == null ? def : v; }
}catch{}
return def;
}
async function setSetting(key, val){
try{
if (typeof GM !== "undefined" && GM.setValue) return await GM.setValue(key, val);
if (typeof GM_setValue !== "undefined") return GM_setValue(key, val);
}catch{}
}
function setError(msg){
const s = document.getElementById(`${SECTION_ID}-status`);
if (!s) return;
if (msg){
s.textContent = msg;
s.style.display = 'inline';
} else {
s.textContent = '';
s.style.display = 'none';
}
}
// GM XHR → JSON with better Torn API error surfacing
const httpGetJSON = (url) => {
const fn = (typeof GM !== "undefined" && GM.xmlHttpRequest) ? GM.xmlHttpRequest : GM_xmlhttpRequest;
return new Promise((resolve, reject) => {
fn({
method: "GET", url, headers: { Accept: "application/json" }, timeout: 30000,
onload: (res) => {
if (!(res.status >= 200 && res.status < 300)) return reject(new Error(`HTTP ${res.status}`));
try{
const data = JSON.parse(res.responseText);
if (data && (data.error || data.code)){
const code = Number(data.error?.code ?? data.code ?? NaN);
const raw = data.error?.error ?? data.error?.message ?? "API error";
const map = {
1: "Missing API key",
2: "Invalid API key",
5: "Rate limited: retry in ~30s",
7: "Requires Faction API Access",
14: "Daily usage limit reached",
16: `Insufficient access — requires ${REQUIRED_ACCESS}`,
};
const nice = Number.isFinite(code) ? (map[code] || raw) : raw;
reject(new Error(`Torn API error${Number.isFinite(code)?` ${code}`:""}: ${nice}`));
} else {
resolve(data);
}
}catch{
reject(new Error("Invalid JSON response"));
}
},
onerror: () => reject(new Error("Network error")),
ontimeout: () => reject(new Error("Request timed out")),
});
});
};
// If Torn’s app.css gradients aren’t present on this page, add a local fallback
function applyHeaderPolyfillIfNeeded(){
const h = document.querySelector(`#${SECTION_ID} .header___f_BFs`);
if (!h) return;
const bgImg = getComputedStyle(h).backgroundImage || "";
if (bgImg && bgImg !== "none") return;
const s = document.createElement("style");
s.id = `${SECTION_ID}-header-polyfill`;
s.textContent = `
#${SECTION_ID} .header___f_BFs{
background: linear-gradient(180deg,#555,#333) no-repeat;
border-bottom: 2px solid transparent;
border-radius: 5px 5px 0 0;
display:flex; height:34px; position:relative;
}
#${SECTION_ID} .title___nIMRx{
align-self:center; color:#fff; font:700 12px/14px Arial,sans-serif;
margin-left:10px; text-shadow:0 0 2px #000; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
`;
(document.head || document.documentElement).appendChild(s);
}
////////////////////////////////////////////////////////////////////////////
// API HELPERS
////////////////////////////////////////////////////////////////////////////
const asCrimes = (p) =>
Array.isArray(p?.crimes) ? p.crimes
: (p?.crimes && typeof p.crimes === "object" ? Object.values(p.crimes)
: (Array.isArray(p?.result?.crimes) ? p.result.crimes : []));
const asNews = (p) =>
Array.isArray(p?.news) ? p.news
: (p?.news && typeof p.news === "object" ? Object.values(p.news)
: (Array.isArray(p?.result?.news) ? p.result.news : []));
// Crimes: one DESC batch filtered by executed_at [startSec..endSec]
async function fetchCrimesBatch({ key, startSec, endSec, signal }){
const u = new URL(CRIMES_API);
u.searchParams.set("comment", API_COMMENT);
u.searchParams.set("key", key.trim());
u.searchParams.set("cat", "completed");
u.searchParams.set("filter", "executed_at");
u.searchParams.set("from", String(startSec));
u.searchParams.set("to", String(endSec));
u.searchParams.set("sort", "DESC");
const data = await httpGetJSON(u.toString());
if (signal?.aborted) throw new Error("Aborted");
const crimes = (asCrimes(data) || []).filter(c => Number.isFinite(c?.executed_at));
return { crimes };
}
// News: walk DESC over [startSec..endSec], sum "balance by $X" amounts per crimeId
async function fetchNewsExpenses({ key, startSec, endSec, includeCrimeIds, signal }){
const byCrime = new Map();
let cursorTo = endSec;
while (cursorTo >= startSec) {
if (signal?.aborted) throw new Error("Aborted");
const u = new URL(NEWS_API);
u.searchParams.set("comment", API_COMMENT);
u.searchParams.set("key", key.trim());
u.searchParams.set("cat", "depositFunds");
u.searchParams.set("sort", "DESC");
u.searchParams.set("from", String(startSec));
u.searchParams.set("to", String(cursorTo));
const news = (asNews(await httpGetJSON(u.toString())) || []).filter(n => Number.isFinite(n?.timestamp));
const count = news.length;
if (!count) break;
for (const n of news) {
const txt = String(n?.text || "");
const idm = txt.match(/crimeId=(\d+)/i); if (!idm) continue;
const crimeId = Number(idm[1]);
if (includeCrimeIds && !includeCrimeIds.has(crimeId)) continue;
const mm = txt.match(/balance\s+by\s+\$([\d, ]+)/i) || txt.match(/\$([\d, ]+)/);
if (!mm) continue;
const amt = parseInt(mm[1].replace(/[^\d]/g,""), 10) || 0;
if (!amt) continue;
byCrime.set(crimeId, (byCrime.get(crimeId) || 0) + amt);
}
if (count < API_PAGE_CAP) break;
// Slide window down (DESC) with a safe overlap using the 2nd smallest timestamp
const asc = [...new Set(news.map(n => n.timestamp))].sort((a,b)=>a-b);
const min = asc[0];
const secondMin = asc.length >= 2 ? asc[1] : NaN;
let nextTo = Number.isFinite(secondMin) ? secondMin : (min - 1);
if (!(nextTo < cursorTo)) nextTo = cursorTo - 1;
if (nextTo < startSec) break;
cursorTo = nextTo;
await delay(150);
}
return byCrime;
}
// Items catalog (with 24h cache): value.market_price
async function ensureItemInfo(key){
if (ITEM_INFO) return;
try{
const cached = await getSetting(ITEM_INFO_CACHE_KEY, null);
if (cached){
const obj = typeof cached === "string" ? JSON.parse(cached) : cached;
if (obj?.ts && obj?.data && (Date.now() - obj.ts) < ITEM_INFO_CACHE_TTL_MS){
ITEM_INFO = new Map(obj.data);
return;
}
}
}catch{/* ignore */}
try{
const u = new URL(ITEMS_API);
u.searchParams.set("comment", API_COMMENT);
u.searchParams.set("key", key.trim());
const data = await httpGetJSON(u.toString());
const arr = Array.isArray(data?.items) ? data.items
: (Array.isArray(data?.result?.items) ? data.result.items : []);
const map = new Map();
for (const it of arr){
const id = Number(it?.id);
if (!Number.isFinite(id)) continue;
const name = it?.name || `Item #${id}`;
const mv = Number(it?.value?.market_price ?? 0) || 0;
map.set(id, { name, mv });
}
ITEM_INFO = map;
try{
await setSetting(ITEM_INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: Array.from(map.entries()) }));
}catch{/* ignore */}
}catch(e){
console.warn("Items catalog fetch failed:", e);
ITEM_INFO = new Map();
}
}
const itemName = (id) => ITEM_INFO?.get(id)?.name ?? `Item #${id}`;
const itemMV = (id) => ITEM_INFO?.get(id)?.mv ?? 0;
////////////////////////////////////////////////////////////////////////////
// AGGREGATION
////////////////////////////////////////////////////////////////////////////
function aggregate(crimes){
const totals = {
count: 0, success: 0, fail: 0,
money: 0, respect: 0,
payoutToMembers: 0, payoutToFaction: 0,
ocBreakdown: new Map(), // key: "name|diff" → bucket
};
for (const c of crimes){
const success = String(c?.status||"").toLowerCase() === "successful";
totals.count += 1;
success ? (totals.success += 1) : (totals.fail += 1);
const name = c?.name || "Unknown OC";
const diff = Number.isFinite(c?.difficulty) ? c.difficulty : null;
const key = `${name}|${diff ?? ""}`;
let b = totals.ocBreakdown.get(key);
if (!b){
b = { name, difficulty: diff, total: 0, success: 0, fail: 0, respect: 0, memMoney: 0, income: 0, itemsQty: 0, items: new Map() };
totals.ocBreakdown.set(key, b);
}
b.total += 1;
success ? (b.success += 1) : (b.fail += 1);
const money = Number(c?.rewards?.money || 0);
const respect = Number(c?.rewards?.respect || 0);
totals.money += money;
totals.respect += respect;
b.income += money;
b.respect += respect;
const itemsArr = Array.isArray(c?.rewards?.items) ? c.rewards.items : [];
for (const it of itemsArr){
const iid = Number(it?.id);
const qty = Number(it?.quantity || 0);
if (!Number.isFinite(iid) || !qty) continue;
b.itemsQty += qty;
b.items.set(iid, (b.items.get(iid) || 0) + qty);
}
// payout percentage = share to members
const pct = Math.max(0, Math.min(100, Number(c?.rewards?.payout?.percentage ?? 0)));
const toMembers = Math.round((money * pct) / 100);
const toFaction = money - toMembers;
totals.payoutToMembers += toMembers;
totals.payoutToFaction += toFaction;
b.memMoney += toMembers;
}
return { totals };
}
function buildOcExpenseMap(crimes, expenseByCrime){
const map = new Map();
for (const c of crimes){
const e = expenseByCrime.get(c.id) || 0;
if (!e) continue;
const name = c.name || "Unknown OC";
const diff = Number.isFinite(c.difficulty) ? c.difficulty : null;
const key = `${name}|${diff ?? ""}`;
map.set(key, (map.get(key) || 0) + e);
}
return map;
}
function getMaxPaidAt(crimes){
let max = NaN;
for (const c of crimes){
const ts = Number(c?.rewards?.payout?.paid_at);
if (Number.isFinite(ts) && (!Number.isFinite(max) || ts > max)) max = ts;
}
return max;
}
function buildItemEstByKey(ocMap){
const byKey = new Map(); let total = 0;
for (const [key, b] of ocMap){
let est = 0;
if (b.items?.size){
for (const [iid, qty] of b.items) est += qty * (itemMV(iid) || 0);
}
est = Math.round(est);
byKey.set(key, est);
total += est;
}
return { byKey, total: Math.round(total) };
}
////////////////////////////////////////////////////////////////////////////
// UI
////////////////////////////////////////////////////////////////////////////
function injectStyle(){
if (document.getElementById(`${SECTION_ID}-style`)) return;
const s = document.createElement("style");
s.id = `${SECTION_ID}-style`;
s.textContent = style;
document.head.appendChild(s);
}
function buildWidget(){
const existing = document.getElementById(SECTION_ID);
if (existing) return existing;
const el = document.createElement("div");
el.id = SECTION_ID;
el.className = "equipped-items-wrap";
el.innerHTML = `
<div class="main___QuzF7">
<header class="header___f_BFs">
<p class="title___nIMRx" role="heading" aria-level="2">${TITLE_TEXT}</p>
<nav class="icons___VmEI4">
<button type="button" class="button___MO5cW iconParentButton___POutJ" id="${SECTION_ID}-toggle" aria-label="Open" aria-expanded="false">${OPEN_SVG}</button>
</nav>
</header>
<div class="content___Gb8DR" id="${SECTION_ID}-content" hidden>
<div class="pills-row">
<span id="${SECTION_ID}-apikeybar" class="pill"></span>
<span class="pill pill-date">From <input type="date" id="${SECTION_ID}-from"></span>
<span class="pill pill-date">To <input type="date" id="${SECTION_ID}-to"></span>
<span id="${SECTION_ID}-status" class="status-err"></span>
<span class="pills-right">
<button class="btn btn-icon" id="${SECTION_ID}-stop" title="Stop" disabled>${ICONS.stop}</button>
<button class="btn btn-icon" id="${SECTION_ID}-run" title="Run">${ICONS.play}</button>
</span>
</div>
<div id="${SECTION_ID}-totals"></div>
<div class="grid">
<div class="card card--oc">
<h4><span>OC breakdown</span></h4>
<div id="${SECTION_ID}-ocbreak">—</div>
</div>
</div>
</div>
</div>
`;
el.querySelector(`#${SECTION_ID}-toggle`)?.addEventListener("click", () => togglePanel());
return el;
}
function togglePanel(force){
const content = document.getElementById(`${SECTION_ID}-content`);
const btn = document.getElementById(`${SECTION_ID}-toggle`);
if (!content || !btn) return;
const isOpen = !content.hasAttribute("hidden");
const show = (typeof force === "boolean") ? force : !isOpen;
content.toggleAttribute("hidden", !show);
btn.setAttribute("aria-expanded", String(show));
btn.setAttribute("aria-label", show ? "Collapse" : "Open");
btn.innerHTML = show ? COLLAPSE_SVG : OPEN_SVG;
}
async function refreshApiUI(){
const span = document.getElementById(`${SECTION_ID}-apikeybar`);
if (!span) return;
const key = (await getSetting("apiKey","")).trim();
span.className = "pill";
span.innerHTML = `API key: ${
key ? `<strong>set</strong> · <a id="${SECTION_ID}-editkey">edit</a>`
: `<strong>not set</strong> · <a id="${SECTION_ID}-editkey">set</a>`
}`;
document.getElementById(`${SECTION_ID}-editkey`)?.addEventListener("click", async (ev)=>{
ev.preventDefault();
const v = prompt("Paste your Torn API key", key) || "";
await setSetting("apiKey", v.trim());
refreshApiUI();
});
setError(key ? "" : `API key required (${REQUIRED_ACCESS}).`);
}
function renderTotals({ totals, countDays, paidToMembersOverride, netOverride }){
const host = document.getElementById(`${SECTION_ID}-totals`);
if (!host) return;
const days = Math.max(1, countDays|0);
const rate = totals.count ? (totals.success / totals.count * 100) : 0;
const paidMembers = Number.isFinite(paidToMembersOverride) ? paidToMembersOverride : (totals.payoutToMembers || 0);
const netFaction = Number.isFinite(netOverride) ? netOverride : (totals.payoutToFaction || 0);
const grandTotal = paidMembers + netFaction;
const perRespect = totals.respect / days;
const perMem = paidMembers / days;
const perNet = netFaction / days;
const perGrand = grandTotal / days;
host.innerHTML = `
<div class="kpis">
<div class="kpi">
<div class="label">Crimes</div>
<div class="value">${totals.count.toLocaleString()}</div>
<div class="sub"><span class="good">${totals.success.toLocaleString()}</span> / <span class="bad">${totals.fail.toLocaleString()}</span> – ${rate.toFixed(1)}%</div>
</div>
<div class="kpi">
<div class="label">Respect</div>
<div class="value">${totals.respect.toLocaleString()}</div>
<div class="sub">≈ ${perRespect.toFixed(1)} / day</div>
</div>
<div class="kpi">
<div class="label">Money to members</div>
<div class="value">$${paidMembers.toLocaleString()}</div>
<div class="sub">≈ $${fmtMoneySpace(perMem)} / day</div>
</div>
<div class="kpi">
<div class="label">Money to faction</div>
<div class="value">$${netFaction.toLocaleString()}</div>
<div class="sub">≈ $${fmtMoneySpace(perNet)} / day</div>
</div>
<div class="kpi">
<div class="label">Grand total</div>
<div class="value">$${grandTotal.toLocaleString()}</div>
<div class="sub">≈ $${fmtMoneySpace(perGrand)} / day</div>
</div>
</div>
`;
}
function renderOcBreakdown(ocMap, ocExpenseMap = new Map(), itemEstByKey = new Map()){
const host = document.getElementById(`${SECTION_ID}-ocbreak`);
if (!host) return;
const entries = Array.from(ocMap.entries()).map(([key, b]) => ({ key, ...b }));
if (!entries.length){ host.innerHTML = `<span class="kpi sub">No crimes in range.</span>`; return; }
// By difficulty ASC then name
entries.sort((a,b)=> ( (a.difficulty??9999) - (b.difficulty??9999) ) || a.name.localeCompare(b.name));
const rows = entries.map(x=>{
const rate = x.total ? (x.success / x.total * 100) : 0;
const income = Math.round(x.income || 0);
const paid = Math.round(ocExpenseMap.get(x.key) || 0);
const est = Math.round(itemEstByKey.get(x.key) || 0);
const net = income + est - paid;
const netCls = net >= 0 ? "good" : "bad";
const label = `${x.difficulty} - ${escapeHtml(x.name)}`;
const parts = [];
if (income > 0) parts.push(`<div class="good">$${income.toLocaleString()}</div>`);
if (x.items?.size){
const li = [];
for (const [iid, qty] of x.items) li.push(`<li>${qty.toLocaleString()}× ${escapeHtml(itemName(iid))}</li>`);
li.sort((a,b)=> a.localeCompare(b));
parts.push(`<div class="items-col"><ul class="items-list">${li.join("")}</ul></div>`);
if (est > 0) parts.push(`<div class="warn">≈ $${est.toLocaleString()}</div>`);
}
const merged = parts.length ? parts.join("") : "—";
const runsCell = `<span class="mono">${pad2(x.total)} (<span class="good">${pad2(x.success)}</span>/<span class="bad">${pad2(x.fail)}</span>) ${padPct(rate)}</span>`;
return `<tr>
<td class="oc-name">${label}</td>
<td class="w-min center">${runsCell}</td>
<td class="w-min center">${x.respect.toLocaleString()}</td>
<td>${merged}</td>
<td class="w-min bad">$${paid.toLocaleString()}</td>
<td class="w-min ${netCls}">$${net.toLocaleString()}</td>
</tr>`;
});
host.innerHTML = `
<table class="table--compact">
<thead>
<tr>
<th>OC</th>
<th class="w-min">Runs</th>
<th class="w-min">Resp.</th>
<th>Income & Items (Est. MV)</th>
<th class="w-min">Paid to Members</th>
<th class="w-min">Net</th>
</tr>
</thead>
<tbody>${rows.join("")}</tbody>
</table>
`;
}
////////////////////////////////////////////////////////////////////////////
// CONTROLLER
////////////////////////////////////////////////////////////////////////////
let abortCtrl = null;
async function runQuery(){
const key = (await getSetting("apiKey","")).trim();
if (!key){ setError(`API key required (${REQUIRED_ACCESS}).`); return; }
const fromStr = document.getElementById(`${SECTION_ID}-from`)?.value?.trim();
const toStr = document.getElementById(`${SECTION_ID}-to`)?.value?.trim();
if (!fromStr || !toStr) return alert("Pick both dates.");
const toUTC = (y,m,d,h=0,mi=0,s=0)=>Math.floor(Date.UTC(y,m-1,d,h,mi,s)/1000);
const startSec = toUTC(+fromStr.slice(0,4), +fromStr.slice(5,7), +fromStr.slice(8,10), 0,0,0);
const endSec = toUTC(+toStr.slice(0,4), +toStr.slice(5,7), +toStr.slice(8,10), 23,59,59);
if (endSec < startSec) return alert("“To” must be ≥ “From”.");
const btnRun = document.getElementById(`${SECTION_ID}-run`);
const btnStop = document.getElementById(`${SECTION_ID}-stop`);
abortCtrl = new AbortController();
btnRun.disabled = true; btnStop.disabled = false; setError("");
try {
// Page through crimes DESC with second-oldest overlap
const seenIds = new Set(); const collected = [];
let cursorTo = endSec;
while (cursorTo >= startSec) {
if (abortCtrl.signal.aborted) throw new Error("Aborted");
const { crimes } = await fetchCrimesBatch({ key, startSec, endSec: cursorTo, signal: abortCtrl.signal });
const count = crimes.length;
if (!count) break;
for (const c of crimes) {
const id = c?.id;
if (id != null && !seenIds.has(id)) { seenIds.add(id); collected.push(c); }
}
if (count < API_PAGE_CAP) break;
const asc = [...new Set(crimes.map(c => c.executed_at))].sort((a,b)=>a-b);
const min = asc[0], secondMin = asc.length >= 2 ? asc[1] : NaN;
let nextTo = Number.isFinite(secondMin) ? secondMin : (min - 1);
if (!(nextTo < cursorTo)) nextTo = cursorTo - 1;
if (nextTo < startSec) break;
cursorTo = nextTo;
await delay(150);
}
const ranged = collected.filter(c => c.executed_at >= startSec && c.executed_at <= endSec);
// Items MV cache + IDs set for news filter
await ensureItemInfo(key);
const idSet = new Set(ranged.map(c => c.id));
const maxPaid = getMaxPaidAt(ranged);
const newsEnd = Number.isFinite(maxPaid) ? Math.max(endSec, maxPaid) : endSec;
// News payouts → by OC
let ocExpenseMap = new Map();
try{
const expenseByCrime = await fetchNewsExpenses({ key, startSec, endSec: newsEnd, includeCrimeIds: idSet, signal: abortCtrl.signal });
ocExpenseMap = buildOcExpenseMap(ranged, expenseByCrime);
}catch{/* ignore news errors */}
const diffDays = Math.ceil(((endSec - startSec + 1) * 1000) / (24*3600*1000));
const { totals } = aggregate(ranged);
// Item estimate totals + header numbers
const { byKey: itemEstByKey, total: itemEstTotal } = buildItemEstByKey(totals.ocBreakdown);
let totalIncome = 0; for (const b of totals.ocBreakdown.values()) totalIncome += Math.round(b.income || 0);
let totalPaid = 0; for (const v of ocExpenseMap.values()) totalPaid += Math.round(v || 0);
const netTotal = totalIncome + itemEstTotal - totalPaid;
renderTotals({ totals, countDays: diffDays, paidToMembersOverride: totalPaid, netOverride: netTotal });
renderOcBreakdown(totals.ocBreakdown, ocExpenseMap, itemEstByKey);
} catch (e){
if (e?.message !== "Aborted") setError(e?.message || "Unexpected error.");
} finally {
btnRun.disabled = false; btnStop.disabled = true;
}
}
function wireUp(){
document.getElementById(`${SECTION_ID}-run`) ?.addEventListener("click", runQuery);
document.getElementById(`${SECTION_ID}-stop`)?.addEventListener("click", () => abortCtrl?.abort());
// Default range: last 7 days (UTC)
const fromEl = document.getElementById(`${SECTION_ID}-from`);
const toEl = document.getElementById(`${SECTION_ID}-to`);
if (fromEl && toEl){
const now = new Date();
const toY=now.getUTCFullYear(), toM=now.getUTCMonth()+1, toD=now.getUTCDate();
const from = new Date(Date.UTC(toY, toM-1, toD-6));
const iso = (d) => `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,"0")}-${String(d.getUTCDate()).padStart(2,"0")}`;
if (!fromEl.value) fromEl.value = iso(from);
if (!toEl.value) toEl.value = iso(now);
}
}
////////////////////////////////////////////////////////////////////////////
// MOUNTING
////////////////////////////////////////////////////////////////////////////
function findAnchors(){
const btns = document.querySelector("#faction-crimes-root .buttonsContainer___aClaa");
if (btns && btns.parentElement) return { parent: btns.parentElement, before: btns };
const tabs = document.querySelector(".faction-tabs");
if (tabs && tabs.parentElement) return { parent: tabs.parentElement, before: tabs.nextSibling };
const crimes = document.getElementById("faction-crimes") || document.getElementById("faction-crimes-root");
if (crimes) return { parent: crimes, before: crimes.firstChild };
return null;
}
function insertUI(){
const anchors = findAnchors();
if (!anchors) return false;
const { parent, before } = anchors;
const widget = buildWidget();
if (!widget.isConnected) parent.insertBefore(widget, before || null);
applyHeaderPolyfillIfNeeded();
togglePanel(false);
refreshApiUI();
wireUp();
return true;
}
function observe(){
let booted = false;
const tryBoot = () => { if (!booted && insertUI()) booted = true; };
tryBoot();
new MutationObserver(() => { if (!booted) tryBoot(); })
.observe(document.documentElement, { childList:true, subtree:true });
}
function registerMenus(){
const reg = (label, fn) => {
if (typeof GM !== "undefined" && GM.registerMenuCommand) GM.registerMenuCommand(label, fn);
else if (typeof GM_registerMenuCommand !== "undefined") GM_registerMenuCommand(label, fn);
};
reg("Set Torn API key", async () => {
const cur = await getSetting("apiKey","");
const v = prompt("Enter your Torn API key (requires Limited Access):", cur || "");
if (v !== null) await setSetting("apiKey", v.trim());
refreshApiUI();
});
}
////////////////////////////////////////////////////////////////////////////
// BOOT
////////////////////////////////////////////////////////////////////////////
(function boot(){
injectStyle();
registerMenus();
observe();
})();
})();