您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Uses Torn API to list Organized Crime metrics for your faction.
// ==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(); })(); })();