Live Torn bounty board filter — min reward, FFScouter fair-fight range, Okay/Hospital status — with clickable attack toasts. Desktop + Torn PDA.
// ==UserScript==
// @name Bounty Hunter
// @namespace https://github.com/eugene-torn-scripts/bounty-hunter
// @version 1.3.0
// @description Live Torn bounty board filter — min reward, FFScouter fair-fight range, Okay/Hospital status — with clickable attack toasts. Desktop + Torn PDA.
// @author lannav
// @match https://www.torn.com/*
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect api.torn.com
// @connect ffscouter.com
// @license GPL-3.0-or-later
// ==/UserScript==
/*
* Bounty Hunter
* Copyright (C) 2026 lannav
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details: https://www.gnu.org/licenses/gpl-3.0.html
*
* Source: https://github.com/eugene-torn-scripts/bounty-hunter
*/
/* eslint-disable no-undef */
(function () {
"use strict";
// ════════════════════════════════════════════════════════════
// CONSTANTS & CONFIG
// ════════════════════════════════════════════════════════════
// Torn PDA substitutes this literal at load time with the user's public key.
// On desktop / non-PDA, it stays as the placeholder and we fall back to
// localStorage (optionally reusing Supply Pack Analyzer's saved key).
const PDA_API_KEY = "###PDA-APIKEY###";
const PDA_PLACEHOLDER = "###" + "PDA-APIKEY" + "###"; // split to avoid self-substitution
const VERSION = "1.3.0";
const LS = {
apiKey: "bh_apiKey",
ffKey: "bh_ffscouterKey",
settings: "bh_settings",
shared: "bh_shared", // cross-tab: last refresh result (matches + metadata)
debug: "bh_debug", // "1" when the debug log is on; undefined/"" otherwise
};
// When shared data is younger than (refreshSec * SHARED_FRESH_RATIO) ms,
// a tab skips its own refresh and free-rides on the writer's result.
// 0.8 leaves headroom for clock drift between tabs that started close
// together — we'd rather free-ride than double-fetch.
const SHARED_FRESH_RATIO = 0.8;
const SPA_LS_APIKEY = "spa_apiKey"; // reuse SPA's key on desktop if present
// ════════════════════════════════════════════════════════════
// DEBUG LOG — opt-in ring buffer surfaced in the Settings tab.
// Goal: let the user SEE requests firing and rate-limit hits
// in real time without opening devtools.
// ════════════════════════════════════════════════════════════
const debugLog = [];
const MAX_DEBUG_LOG = 150;
// Cheap pub-sub so the Settings tab can re-render the log as new
// entries land. document is always present before the IIFE runs.
const debugBus = document.createElement("div");
function isDebugOn() {
try { return localStorage.getItem(LS.debug) === "1"; } catch { return false; }
}
// Strip host noise and redact the `key=` query param so Torn/FFScouter
// API keys never end up in a log the user might screenshot or paste.
function redactUrl(url) {
try {
const u = new URL(url);
const params = new URLSearchParams(u.search);
for (const k of ["key", "apikey"]) {
if (params.has(k)) params.set(k, "***");
}
const q = params.toString();
return `${u.host}${u.pathname}${q ? "?" + q : ""}`;
} catch {
return url;
}
}
// `level` is one of "info" | "ok" | "warn" | "err" — drives the row colour.
function logDebug(label, level = "info", ms = null) {
if (!isDebugOn()) return;
debugLog.push({
ts: new Date().toISOString().slice(11, 19),
label,
level,
ms: (typeof ms === "number") ? Math.round(ms) : null,
});
if (debugLog.length > MAX_DEBUG_LOG) debugLog.shift();
debugBus.dispatchEvent(new CustomEvent("entry"));
}
const API_BASE = "https://api.torn.com/v2";
const FF_BASE = "https://ffscouter.com/api/v1";
const API_DELAY_MS = 750;
// Direct attack URL — lands on the fight page for the target. Torn
// auto-credits the bounty when the target is hospitalised, so we don't
// need `&bounty=<contract_id>` (which isn't in the public API anyway).
const ATTACK_URL = "https://www.torn.com/page.php?sid=attack&user2ID=";
const PROFILE_URL = "https://www.torn.com/profiles.php?XID=";
const DEFAULT_SETTINGS = {
minPrice: 500_000,
minFF: 1.0,
maxFF: 3.0,
hospitalMaxMin: 5,
refreshSec: 60,
toastsEnabled: true,
includeUnknownFF: false,
};
// Torn New Player Protection (https://wiki.torn.com/wiki/New_Player_Protection):
// - NPP lasts 14 days (age 0..13). At age >= 14 the player loses NPP.
// - A non-NPP player cannot attack an NPP player.
// - An NPP player CAN attack another NPP player, but not in the target's first 24 h.
// - (Edge case we ignore: faction-war participation lifts NPP temporarily.)
const NPP_DAYS = 14;
function isAttackableByAge(targetAge, myAge) {
if (targetAge == null) return true; // unknown → don't drop, Torn will reject on attack if any
const meUnderNPP = (myAge != null) && (myAge < NPP_DAYS);
if (meUnderNPP) {
// Both under NPP → target must be past the 24-hour hard block.
return targetAge >= 1;
}
// We're past NPP → target must also be past NPP to be attackable.
return targetAge >= NPP_DAYS;
}
// Physical-country derivation from Torn v2 status. Used to exclude targets
// who are in a different country than us — they aren't reachable from an
// attack page. Return values:
// "Torn" — in Torn (Okay/Jail/Federal, or hospitalised in Torn)
// "<Country>" — abroad (state "Abroad"), or hospitalised in a specific
// foreign country (description "In a <Adjective> hospital")
// null — unknown or "Traveling" (in transit, unattackable anyway)
//
// Adjective→country map covers every Torn travel destination. If a future
// destination ships without an entry here the target is returned as null
// and the country filter falls open — safer than silently dropping them.
const HOSPITAL_ADJ_TO_COUNTRY = {
"Mexican": "Mexico",
"Caymanian": "Cayman Islands",
"Canadian": "Canada",
"Hawaiian": "Hawaii",
"British": "United Kingdom",
"Argentinian": "Argentina",
"Argentine": "Argentina",
"Swiss": "Switzerland",
"Japanese": "Japan",
"Chinese": "China",
"Emirati": "United Arab Emirates",
"South African": "South Africa",
};
function getPlayerCountry(status) {
if (!status || typeof status !== "object") return null;
const state = status.state;
const desc = status.description || "";
if (state === "Okay" || state === "Jail" || state === "Federal") return "Torn";
if (state === "Abroad") {
const m = /^In\s+(.+)$/.exec(desc);
return m ? m[1].trim() : null;
}
if (state === "Hospital") {
if (/^In hospital\b/i.test(desc)) return "Torn";
const m = /^In an?\s+(.+?)\s+hospital\b/i.exec(desc);
if (m) {
const adj = m[1].trim();
return HOSPITAL_ADJ_TO_COUNTRY[adj] || null;
}
return null;
}
return null; // Traveling, or unknown state
}
const TOAST_TIMEOUT_MS = 15_000;
const TOAST_MAX_VISIBLE_DESKTOP = 3;
const TOAST_MAX_VISIBLE_MOBILE = 1;
const MOBILE_MEDIA = "(max-width: 768px)";
function toastMaxVisible() {
try { return window.matchMedia(MOBILE_MEDIA).matches ? TOAST_MAX_VISIBLE_MOBILE : TOAST_MAX_VISIBLE_DESKTOP; }
catch { return TOAST_MAX_VISIBLE_DESKTOP; }
}
const STATUS_CACHE_MS = 20_000;
const STATUS_CONCURRENCY = 3;
const IS_PDA = typeof PDA_httpGet === "function";
const HAS_PDA_KEY = PDA_API_KEY !== PDA_PLACEHOLDER && /^[A-Za-z0-9]{16}$/.test(PDA_API_KEY);
// Desktop userscript managers expose GM_xmlhttpRequest when the script
// requests `@grant GM_xmlhttpRequest`. We prefer it over fetch because
// FFScouter's CORS headers may not allow direct calls from torn.com.
// eslint-disable-next-line no-undef
const GM_XHR = (typeof GM_xmlhttpRequest !== "undefined") ? GM_xmlhttpRequest : null;
// ════════════════════════════════════════════════════════════
// UTILITIES
// ════════════════════════════════════════════════════════════
const fmt = {
money(n) {
if (n == null) return "$0";
const a = Math.abs(n);
const s = a >= 1e9 ? (a / 1e9).toFixed(2) + "B"
: a >= 1e6 ? (a / 1e6).toFixed(2) + "M"
: a >= 1e3 ? (a / 1e3).toFixed(1) + "K"
: a.toLocaleString();
return (n < 0 ? "-$" : "$") + s;
},
moneyFull(n) { return (n < 0 ? "-" : "") + "$" + Math.abs(n).toLocaleString(); },
num(n) { return n == null ? "" : Number(n).toLocaleString(); },
secsToMinLabel(s) {
if (s <= 0) return "0m";
const m = Math.floor(s / 60);
const sec = s % 60;
return m > 0 ? `${m}m ${sec}s`.replace(" 0s", "") : `${sec}s`;
},
hospLabel(untilSec) {
const rem = Math.max(0, untilSec - Math.floor(Date.now() / 1000));
if (rem <= 0) return "🏥 out";
const m = Math.floor(rem / 60);
const s = rem % 60;
if (m === 0) return `🏥 ${s}s`;
return `🏥 ${m}m ${s}s`;
},
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Parse FFScouter's bs_estimate_human ("3.93k" / "2.99b") to a comparable
// number. Returns null for missing/unparseable values so callers can sort
// them to the end.
function parseBS(human) {
if (!human) return null;
const m = String(human).match(/^\s*([\d.]+)\s*([kKmMbB]?)\s*$/);
if (!m) return null;
const n = parseFloat(m[1]);
if (!Number.isFinite(n)) return null;
const s = (m[2] || "").toLowerCase();
const mult = s === "k" ? 1e3 : s === "m" ? 1e6 : s === "b" ? 1e9 : 1;
return n * mult;
}
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """);
}
function loadSettings() {
try {
const raw = localStorage.getItem(LS.settings);
return { ...DEFAULT_SETTINGS, ...(raw ? JSON.parse(raw) : {}) };
} catch { return { ...DEFAULT_SETTINGS }; }
}
function saveSettings(s) {
localStorage.setItem(LS.settings, JSON.stringify(s));
}
// Open a Torn URL in a way that works on desktop and inside the PDA webview.
// `window.open` is unreliable in PDA — synthesising an anchor click lets the
// platform handle the navigation natively (new tab on desktop, in-app on PDA).
function openTornUrl(url) {
const a = document.createElement("a");
a.href = url;
a.target = "_blank";
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
a.remove();
}
// ════════════════════════════════════════════════════════════
// HTTP — uses PDA_httpGet inside PDA, fetch on desktop
// ════════════════════════════════════════════════════════════
function _gmGet(url) {
return new Promise((resolve, reject) => {
GM_XHR({
method: "GET",
url,
onload: (r) => resolve({ status: r.status, text: r.responseText }),
onerror: () => reject(new Error("network_error")),
ontimeout: () => reject(new Error("timeout")),
});
});
}
async function _httpGetOnceRaw(url) {
if (IS_PDA) {
const res = await PDA_httpGet(url);
if (res.status < 200 || res.status >= 300) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.body = res.responseText;
throw err;
}
return JSON.parse(res.responseText);
}
if (GM_XHR) {
const res = await _gmGet(url);
if (res.status < 200 || res.status >= 300) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.body = res.text;
throw err;
}
return JSON.parse(res.text);
}
const res = await fetch(url);
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.body = await res.text().catch(() => "");
throw err;
}
return res.json();
}
// Debug-logging wrapper around the raw HTTP call. Records status + ms
// for every request so the user can watch traffic in real time.
async function _httpGetOnce(url) {
const started = performance.now();
const redacted = redactUrl(url);
try {
const data = await _httpGetOnceRaw(url);
const ms = performance.now() - started;
// Torn returns 200 OK with { error: { code: 5, error: "Too many requests" } }
// on rate limit. Surface that in the log even though the HTTP layer saw 200.
const tornCode = data && data.error && data.error.code;
if (tornCode === 5) {
logDebug(`GET ${redacted} → 200 · Torn rate limit (code 5)`, "err", ms);
} else if (tornCode) {
logDebug(`GET ${redacted} → 200 · Torn code ${tornCode}`, "warn", ms);
} else {
logDebug(`GET ${redacted} → 200`, "ok", ms);
}
return data;
} catch (err) {
const ms = performance.now() - started;
const status = err.status || "ERR";
const level = (status === 429) ? "err" : "warn";
const tag = (status === 429) ? " · rate limit" : "";
logDebug(`GET ${redacted} → ${status}${tag}`, level, ms);
throw err;
}
}
// One retry for transient / PDA-flaky failures. Real HTTP errors fall through.
async function httpGetJson(url) {
try { return await _httpGetOnce(url); }
catch (err) {
if (err.status >= 400 && err.status < 500) throw err;
logDebug(`retry after network/5xx error`, "warn");
await sleep(400);
return _httpGetOnce(url);
}
}
// Rate-limit signal for both Torn (JSON envelope error.code=5) and
// FFScouter/HTTP layer (status 429). When true, the caller should abort
// the whole refresh cycle so prior matches stay on screen instead of
// being overwritten with a partial/empty set.
function isRateLimitError(err) {
if (!err) return false;
if (err.status === 429) return true;
if (err.tornCode === 5) return true;
return false;
}
// ════════════════════════════════════════════════════════════
// TORN API CLIENT (rate-limited, 750 ms min gap)
// ════════════════════════════════════════════════════════════
class TornAPI {
constructor(key) {
this.key = key;
this._lastReq = 0;
}
setKey(key) { this.key = key; }
async _rateLimit() {
const wait = API_DELAY_MS - (Date.now() - this._lastReq);
if (wait > 0) await sleep(wait);
this._lastReq = Date.now();
}
async _get(pathOrUrl, params = null) {
await this._rateLimit();
const isFull = /^https?:\/\//.test(pathOrUrl);
const url = new URL(isFull ? pathOrUrl : API_BASE + pathOrUrl);
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
if (!url.searchParams.has("key")) url.searchParams.set("key", this.key);
const data = await httpGetJson(url.toString());
if (data && data.error) {
const e = new Error(data.error.error || "Torn API error");
e.tornCode = data.error.code;
throw e;
}
return data;
}
async fetchAllBounties() {
const all = [];
let url = `${API_BASE}/torn/bounties?limit=100&offset=0`;
let delay = 0;
let safety = 10; // cap pagination at 1000 bounties
while (url && safety-- > 0) {
const data = await this._get(url);
if (Array.isArray(data.bounties)) all.push(...data.bounties);
if (typeof data.bounties_delay === "number") delay = data.bounties_delay;
const next = data._metadata && data._metadata.links && data._metadata.links.next;
if (!next || !data.bounties || data.bounties.length === 0) break;
url = next;
}
return { bounties: all, delaySec: delay };
}
async fetchUserProfile(id) {
// /profile includes status + age + level + faction_id in one call,
// which we need to filter out Torn's new-account protection window.
const data = await this._get(`/user/${id}/profile`);
return data.profile || null;
}
async validateKey() {
// /user/profile gives us id + level + age + faction_id in one shot;
// age is needed to evaluate Torn's NPP rule against bounty targets.
const data = await this._get("/user/profile");
return (data && data.profile) || null;
}
}
// ════════════════════════════════════════════════════════════
// FFSCOUTER — bulk fair-fight lookup (up to 205 IDs/call)
// ════════════════════════════════════════════════════════════
// Validates an FFScouter key via /check-key. Format-valid ≠ usable — the
// key must also be registered on FFScouter (get-stats otherwise returns
// code 6 and we silently filter everyone out). /check-key doesn't count
// against the caller's usage.
async function validateFFScouterKey(key) {
if (!/^[A-Za-z0-9]{16}$/.test(key)) {
return { ok: false, message: "Must be 16 alphanumeric characters." };
}
try {
const data = await httpGetJson(`${FF_BASE}/check-key?key=${encodeURIComponent(key)}`);
if (data && data.code) return { ok: false, message: data.error || `FFScouter code ${data.code}` };
if (!data || !data.is_registered) {
return { ok: false, message: "Key is not registered with FFScouter — sign up at ffscouter.com first." };
}
return { ok: true, message: data.is_premium ? "Valid — premium." : "Valid." };
} catch (e) {
return { ok: false, message: e && e.message ? e.message : "Could not reach FFScouter." };
}
}
async function fetchFFScouterStats(key, userIds) {
const result = { map: new Map(), error: null, nullCount: 0, totalReturned: 0 };
if (!key) { result.error = "no_key"; return result; }
if (userIds.length === 0) return result;
for (let i = 0; i < userIds.length; i += 200) {
const chunk = userIds.slice(i, i + 200);
const url = `${FF_BASE}/get-stats?key=${encodeURIComponent(key)}&targets=${chunk.join(",")}`;
try {
const data = await httpGetJson(url);
if (!Array.isArray(data)) {
result.error = (data && data.error) ? data.error : "unexpected_response";
continue;
}
result.totalReturned += data.length;
for (const p of data) {
if (p.fair_fight == null) { result.nullCount++; continue; }
result.map.set(Number(p.player_id), {
ff: Number(p.fair_fight),
bs: p.bs_estimate_human || null,
});
}
} catch (e) {
// Rate-limit aborts the cycle so prior matches stay visible —
// partial FFScouter data would otherwise filter good targets out.
if (isRateLimitError(e)) throw e;
// Surface the first error but keep processing other chunks.
if (!result.error) result.error = e.message || "network_error";
}
}
return result;
}
// ════════════════════════════════════════════════════════════
// KEY RESOLVER
// ════════════════════════════════════════════════════════════
const KeyResolver = {
// Returns the Torn API key if resolvable without user input, else null.
resolveTornKey() {
if (HAS_PDA_KEY) return PDA_API_KEY;
return localStorage.getItem(LS.apiKey) || null;
},
hasSPAKey() {
const k = localStorage.getItem(SPA_LS_APIKEY);
return !!k && /^[A-Za-z0-9]{16}$/.test(k);
},
getSPAKey() { return localStorage.getItem(SPA_LS_APIKEY); },
saveTornKey(k) { localStorage.setItem(LS.apiKey, k); },
clearTornKey() { localStorage.removeItem(LS.apiKey); },
getFFKey() { return localStorage.getItem(LS.ffKey) || ""; },
saveFFKey(k) { localStorage.setItem(LS.ffKey, k); },
clearFFKey() { localStorage.removeItem(LS.ffKey); },
// When the PDA-injected key is in use we don't persist it — it may
// change per session and the user controls it through PDA settings.
isPDAKey() { return HAS_PDA_KEY; },
};
// ════════════════════════════════════════════════════════════
// HUNTER — fetch → filter → render loop
// ════════════════════════════════════════════════════════════
class Hunter {
constructor(api) {
this.api = api;
this.settings = loadSettings();
this.myUserId = null;
this.myUserLevel = null;
this.myUserAge = null; // days since our own signup — drives NPP rule
this.myCountry = null; // "Torn" | "<country>" | null — drives country filter
this.lastMatches = []; // last render's rows
this.lastMatchIds = new Set();
this.lastCounts = null; // { total, afterBasic, afterFF, withFF, ffNull, ffError, statusBreakdown }
this._statusCache = new Map(); // id → { data, fetchedAt }
this._timer = null;
this._running = false;
this._nextAt = 0;
this.lastError = null;
this.onUpdate = null; // ui sets this
this.onToast = null; // toaster sets this
}
updateSettings(next) {
this.settings = { ...this.settings, ...next };
saveSettings(this.settings);
// Reset diff memory so tightening/loosening filters doesn't spam toasts
// with everything, nor suppress legitimate new matches.
this.lastMatchIds = new Set();
// Invalidate the cross-tab cache — its matches were computed with the
// previous filters, so neither this tab nor its siblings should reuse it.
try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
}
// --- Cross-tab sharing ---------------------------------------------
_readShared() {
try {
const raw = localStorage.getItem(LS.shared);
if (!raw) return null;
const s = JSON.parse(raw);
if (!s || typeof s.writtenAt !== "number") return null;
return s;
} catch { return null; }
}
_writeShared() {
try {
const payload = {
writtenAt: Date.now(),
refreshSec: this.settings.refreshSec,
matches: this.lastMatches,
lastCounts: this.lastCounts,
myUserId: this.myUserId,
myUserLevel: this.myUserLevel,
myUserAge: this.myUserAge,
myCountry: this.myCountry,
lastError: this.lastError ? (this.lastError.message || "error") : null,
};
localStorage.setItem(LS.shared, JSON.stringify(payload));
} catch { /* quota or serialization failure — non-fatal */ }
}
// Adopt the "who am I" hints another tab has already resolved; saves one
// /user/profile call on a cold tab that piggybacks on a warmer one.
_adoptSharedIdentity(s) {
if (s.myUserId != null && this.myUserId == null) this.myUserId = s.myUserId;
if (s.myUserLevel != null && this.myUserLevel == null) this.myUserLevel = s.myUserLevel;
if (s.myUserAge != null && this.myUserAge == null) this.myUserAge = s.myUserAge;
// Country is mutable — always adopt the latest writer's value.
if (s.myCountry != null) this.myCountry = s.myCountry;
}
// Apply a new match list: diff against previous to fire toasts, update
// lastMatches/lastMatchIds. Shared by the real-refresh path and the
// cross-tab free-ride path.
_applyMatches(matches) {
const matchKey = (m) => `${m.target_id}|${m.reward}`;
const currentIds = new Set(matches.map(matchKey));
const newOnes = matches.filter((m) => !this.lastMatchIds.has(matchKey(m)));
if (this.settings.toastsEnabled && this.onToast && newOnes.length > 0) {
this.onToast(newOnes);
}
this.lastMatches = matches;
this.lastMatchIds = currentIds;
}
// Called by the `storage` event listener when another tab writes new
// shared data. Updates our in-memory state + re-renders via onUpdate.
adoptSharedPayload(s) {
if (!s) return;
this._adoptSharedIdentity(s);
if (Array.isArray(s.matches)) {
this.lastCounts = s.lastCounts || null;
this._applyMatches(s.matches);
}
if (this.onUpdate) this.onUpdate();
}
stop() {
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
this._running = false;
}
start() {
if (this._running) return;
this._running = true;
this._tick();
}
async _tick() {
if (!this._running) return;
let waitSec = this.settings.refreshSec;
try {
// Cross-tab free-ride: if another tab refreshed recently under
// the same settings, reuse its result instead of burning our
// own API budget. Keeps N-tab users at ~1× call cost.
const shared = this._readShared();
const fresh = shared
&& (Date.now() - shared.writtenAt) < (this.settings.refreshSec * 1000 * SHARED_FRESH_RATIO);
if (fresh) {
logDebug(`free-ride: reusing fresh result from another tab (${shared.matches ? shared.matches.length : 0} matches)`, "info");
this._adoptSharedIdentity(shared);
this.lastCounts = shared.lastCounts || null;
this.lastError = null;
this._applyMatches(shared.matches || []);
if (this.onUpdate) this.onUpdate();
} else {
const delaySec = await this.refresh();
waitSec = Math.max(this.settings.refreshSec, delaySec || 0);
}
} catch (err) {
this.lastError = err;
if (isRateLimitError(err)) {
logDebug(`refresh aborted — rate limit; keeping ${this.lastMatches.length} prior matches on screen`, "err");
} else {
logDebug(`refresh failed: ${err.message || "error"}`, "err");
}
if (this.onUpdate) this.onUpdate();
// Back off briefly on errors; leave auto-refresh alive.
waitSec = Math.max(this.settings.refreshSec, 30);
}
if (this.settings.refreshSec <= 0) return; // "off"
this._nextAt = Date.now() + waitSec * 1000;
this._timer = setTimeout(() => this._tick(), waitSec * 1000);
}
async refresh() {
this.lastError = null;
const refreshStart = performance.now();
logDebug(`refresh: start`, "info");
if (this.onUpdate) this.onUpdate({ loading: true });
// Resolve our ID/level/age/country. ID/level/age are near-immutable
// so we cache them after the first success, but `country` flips
// whenever we travel, so we re-read status on every cycle.
try {
const profile = await this.api.validateKey();
if (profile) {
this.myUserId = profile.id || this.myUserId;
this.myUserLevel = (typeof profile.level === "number") ? profile.level : this.myUserLevel;
this.myUserAge = (typeof profile.age === "number") ? profile.age : this.myUserAge;
this.myCountry = getPlayerCountry(profile.status) || this.myCountry;
}
} catch { /* non-fatal — keep previous identity/country */ }
const { bounties, delaySec } = await this.api.fetchAllBounties();
logDebug(`fetched ${bounties.length} bounties (cache delay ${delaySec || 0}s)`, "ok");
// Collapse multiple bounty rows on the same target + same reward
// into one entry with an aggregated count. Rows with different
// reward amounts stay separate. This avoids duplicate FFScouter
// and profile calls AND cleans up the Hunt list.
const grouped = new Map();
for (const b of bounties) {
const key = `${b.target_id}|${b.reward}`;
if (!grouped.has(key)) {
grouped.set(key, { ...b, bountyCount: 0 });
}
const inc = (typeof b.quantity === "number" && b.quantity > 0) ? b.quantity : 1;
grouped.get(key).bountyCount += inc;
}
const dedupedBounties = [...grouped.values()];
const counts = {
total: bounties.length,
afterBasic: 0,
withFF: 0,
ffNull: 0,
ffError: null,
afterFF: 0,
statusBreakdown: {},
matches: 0,
};
// 1) Price + self filter. Age-based "new-account" filter happens
// later (in step 3) since target age requires a per-user profile
// fetch anyway.
const byBasic = dedupedBounties.filter((b) =>
b.reward >= this.settings.minPrice
&& (this.myUserId == null || b.target_id !== this.myUserId)
);
counts.afterBasic = byBasic.length;
// 2) Bulk FFScouter — keep only rows with a known FF in range.
const ffKey = KeyResolver.getFFKey();
const ids = [...new Set(byBasic.map((b) => Number(b.target_id)))];
logDebug(`FFScouter: requesting ${ids.length} IDs`, "info");
const ff = await fetchFFScouterStats(ffKey, ids);
counts.withFF = ff.map.size;
counts.ffNull = ff.nullCount;
counts.ffError = ff.error;
logDebug(`FFScouter: ${ff.map.size} with FF, ${ff.nullCount} null${ff.error ? `, error: ${ff.error}` : ""}`, ff.error ? "warn" : "ok");
const includeUnknown = !!this.settings.includeUnknownFF;
const byFF = byBasic
.map((b) => {
const e = ff.map.get(Number(b.target_id));
if (e) return { ...b, ff: e.ff, bs: e.bs };
// Target not in FF map (FFScouter returned null FF, or wasn't
// in the response at all). Include if the user opted in.
return includeUnknown ? { ...b, ff: null, bs: null } : null;
})
.filter((b) => {
if (b == null) return false;
if (b.ff == null) return true; // unknown — pass through
return b.ff >= this.settings.minFF && b.ff <= this.settings.maxFF;
});
counts.afterFF = byFF.length;
// 3) Per-target profile — status, age, faction.
const nowSec = Math.floor(Date.now() / 1000);
const hospWindowSec = this.settings.hospitalMaxMin * 60;
logDebug(`profiles: need ${byFF.length} (cache will absorb recent lookups)`, "info");
const profiles = await this._fetchProfiles(byFF.map((b) => Number(b.target_id)));
const matches = [];
counts.tooNew = 0;
counts.differentCountry = 0;
for (const b of byFF) {
const p = profiles.get(Number(b.target_id));
if (!p || !p.status) { counts.statusBreakdown["unknown"] = (counts.statusBreakdown["unknown"] || 0) + 1; continue; }
// Torn's NPP rule — depends on our own age too. See isAttackableByAge().
if (!isAttackableByAge(p.age, this.myUserAge)) {
counts.tooNew++;
continue;
}
const state = p.status.state;
counts.statusBreakdown[state] = (counts.statusBreakdown[state] || 0) + 1;
const until = p.status.until || 0;
const remaining = Math.max(0, until - nowSec);
// Country filter — must be in the same country as us to be
// attackable. Only applied when both sides are known; if we
// can't determine our own location (pre-first-profile, or
// "Traveling") or the target's (unknown hospital adjective),
// the filter falls open so we don't silently drop everyone.
const targetCountry = getPlayerCountry(p.status);
if (this.myCountry && targetCountry && targetCountry !== this.myCountry) {
counts.differentCountry++;
continue;
}
if (state === "Okay") {
matches.push({ ...b, statusState: "Okay", hospUntil: 0 });
} else if (state === "Hospital" && remaining <= hospWindowSec) {
matches.push({ ...b, statusState: "Hospital", hospUntil: until });
}
}
matches.sort((a, b) => b.reward - a.reward);
counts.matches = matches.length;
// 4) Diff for toasts + render + cross-tab broadcast.
this.lastCounts = counts;
this._applyMatches(matches);
this._writeShared();
logDebug(`refresh: done — ${matches.length} matches (of ${counts.total} bounties)`, "ok", performance.now() - refreshStart);
if (this.onUpdate) this.onUpdate({ loading: false });
return delaySec;
}
async _fetchProfiles(ids) {
const out = new Map();
const now = Date.now();
const stale = [];
// De-dupe IDs so repeated target_ids don't trigger parallel
// fetches for the same player. The cache lookup alone isn't
// enough — on a cold start every duplicate would still miss.
const unique = [...new Set(ids)];
for (const id of unique) {
const c = this._statusCache.get(id);
if (c) {
const d = c.data;
// Hospital with a future `until` is effectively locked in —
// the target can't leave hospital unless revived (rare).
// Trust the cache until the timestamp passes.
const hospLocked = d && d.status && d.status.state === "Hospital"
&& d.status.until && (d.status.until * 1000) > now;
if (hospLocked || (now - c.fetchedAt < STATUS_CACHE_MS)) {
out.set(id, d);
continue;
}
}
stale.push(id);
}
// Bounded concurrency — 3 in-flight at a time to stay friendly.
let i = 0;
let rateLimitErr = null;
const workers = Array.from({ length: STATUS_CONCURRENCY }, async () => {
while (i < stale.length && !rateLimitErr) {
const id = stale[i++];
try {
const profile = await this.api.fetchUserProfile(id);
if (profile) {
const data = {
status: profile.status || null,
age: typeof profile.age === "number" ? profile.age : null,
faction_id: profile.faction_id || null,
};
out.set(id, data);
this._statusCache.set(id, { data, fetchedAt: Date.now() });
}
} catch (err) {
// Rate-limit: stop all workers and bubble up so the
// cycle aborts and prior matches stay on screen.
if (isRateLimitError(err)) { rateLimitErr = err; return; }
// Other per-target errors: row just won't match this cycle.
}
}
});
await Promise.all(workers);
if (rateLimitErr) throw rateLimitErr;
return out;
}
secondsUntilRefresh() {
if (!this._nextAt) return 0;
return Math.max(0, Math.round((this._nextAt - Date.now()) / 1000));
}
}
// ════════════════════════════════════════════════════════════
// TOASTER
// ════════════════════════════════════════════════════════════
class Toaster {
constructor() {
this.container = null;
this._cards = []; // { id, el, timer, remaining, enteredAt }
this._clearAllEl = null;
}
ensureContainer() {
if (this.container && document.body.contains(this.container)) return;
const c = document.createElement("div");
c.id = "bh-toasts";
document.body.appendChild(c);
this.container = c;
}
showMany(bounties) {
this.ensureContainer();
// Responsive cap — fewer alerts on phones where screen real estate
// is scarce; more on desktop. Clear-all / overflow cards don't
// count toward the bounty slot budget.
const maxVisible = toastMaxVisible();
const existingBountyCards = this._cards.filter((c) => !c.isMore && !c.isClearAll).length;
const slots = Math.max(0, maxVisible - existingBountyCards);
const toShow = bounties.slice(0, slots);
const overflow = bounties.length - toShow.length;
for (const b of toShow) this._showOne(b);
if (overflow > 0) this._showMoreCard(overflow);
this._updateClearAllButton();
}
_showOne(b) {
const el = document.createElement("div");
el.className = "bh-toast";
const isHosp = b.statusState === "Hospital";
const statusLabel = isHosp ? fmt.hospLabel(b.hospUntil) : "Okay";
const statusClass = isHosp ? "bh-badge-hosp" : "bh-badge-ok";
const hospAttr = isHosp ? ` data-hosp-until="${b.hospUntil}"` : "";
const countBadge = b.bountyCount > 1
? ` <span class="bh-count">×${b.bountyCount}</span>`
: "";
el.innerHTML = `
<button class="bh-toast-close" title="Dismiss">×</button>
<div class="bh-toast-head">
<div class="bh-toast-name">${escHtml(b.target_name)} <span class="bh-toast-lvl">L${b.target_level}</span>${countBadge}</div>
<div class="bh-toast-reward">${escHtml(fmt.money(b.reward))}</div>
</div>
<div class="bh-toast-meta">
<span class="bh-chip">FF ${b.ff == null ? "?" : b.ff.toFixed(2)}</span>
${b.bs ? `<span class="bh-chip">BS ${escHtml(b.bs)}</span>` : ""}
<span class="bh-chip ${statusClass}"${hospAttr}>${statusLabel}</span>
</div>
<button class="bh-toast-attack">Attack →</button>
`;
const card = { id: Number(b.target_id), el, timer: null, remaining: TOAST_TIMEOUT_MS, enteredAt: 0, isMore: false };
const dismiss = () => this._remove(card);
el.querySelector(".bh-toast-close").addEventListener("click", (e) => {
e.stopPropagation();
dismiss();
});
const attack = (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
openTornUrl(ATTACK_URL + b.target_id);
dismiss();
};
el.querySelector(".bh-toast-attack").addEventListener("click", attack);
el.addEventListener("click", attack);
el.addEventListener("pointerenter", () => this._pauseTimer(card));
el.addEventListener("pointerleave", () => this._resumeTimer(card));
this._resumeTimer(card);
this.container.appendChild(el);
this._cards.push(card);
}
_showMoreCard(count) {
// One unified overflow card (don't stack multiple).
const existing = this._cards.find((c) => c.isMore);
if (existing) {
existing.count += count;
existing.el.querySelector(".bh-toast-more-label").textContent = `+${existing.count} more`;
return;
}
const el = document.createElement("div");
el.className = "bh-toast bh-toast-more";
el.innerHTML = `
<div class="bh-toast-more-label">+${count} more</div>
<div class="bh-toast-more-hint">Click to open the Hunt list</div>
`;
const card = { id: null, el, timer: null, remaining: TOAST_TIMEOUT_MS, enteredAt: 0, isMore: true, count };
el.addEventListener("click", () => {
if (window.__bhUI) window.__bhUI.toggle(true);
this._remove(card);
});
el.addEventListener("pointerenter", () => this._pauseTimer(card));
el.addEventListener("pointerleave", () => this._resumeTimer(card));
this._resumeTimer(card);
this.container.appendChild(el);
this._cards.push(card);
}
// Small "Clear all" pill button, appears above the stack when 2+
// toasts are on screen. Lives inside the container as the last DOM
// child so the reversed column flex puts it visually on top.
// Clicking only dismisses the toast cards — Hunt-tab matches are
// driven by Hunter.lastMatches and are untouched.
_updateClearAllButton() {
const toastCount = this._cards.filter((c) => !c.isClearAll).length;
const existing = this._clearAllEl;
if (toastCount < 2) {
if (existing) { existing.remove(); this._clearAllEl = null; }
return;
}
if (existing) {
// Keep it pinned to the visual top regardless of insertion order.
this.container.appendChild(existing);
return;
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = "bh-toast-clear-btn";
btn.textContent = "✕ Clear all";
btn.addEventListener("click", (e) => { e.stopPropagation(); this.clearAll(); });
this.container.appendChild(btn);
this._clearAllEl = btn;
}
_pauseTimer(card) {
if (!card.timer) return;
clearTimeout(card.timer);
card.timer = null;
card.remaining = Math.max(0, card.remaining - (Date.now() - card.enteredAt));
}
_resumeTimer(card) {
card.enteredAt = Date.now();
if (card.timer) clearTimeout(card.timer);
card.timer = setTimeout(() => this._remove(card), card.remaining);
}
_remove(card) {
if (card.timer) clearTimeout(card.timer);
if (card.el && card.el.parentNode) card.el.parentNode.removeChild(card.el);
this._cards = this._cards.filter((c) => c !== card);
// Count dropped — maybe the Clear-all button should vanish too.
this._updateClearAllButton();
}
clearAll() {
for (const c of [...this._cards]) this._remove(c);
if (this._clearAllEl) { this._clearAllEl.remove(); this._clearAllEl = null; }
}
}
// ════════════════════════════════════════════════════════════
// CSS
// ════════════════════════════════════════════════════════════
function injectCSS() {
if (document.getElementById("bh-style")) return;
const style = document.createElement("style");
style.id = "bh-style";
style.textContent = `
#bh-overlay{display:none;position:fixed;inset:0;z-index:999998;background:rgba(0,0,0,.7)}
#bh-panel{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:999999;
background:#1a1a1a;border:1px solid #444;border-radius:10px;overflow:hidden;resize:both;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#ddd;font-size:14px;
width:760px;max-width:100vw;max-height:88vh;min-width:340px;min-height:300px;flex-direction:column}
#bh-panel.bh-open{display:flex}
#bh-overlay.bh-open{display:block}
#bh-panel *{box-sizing:border-box;color:inherit}
#bh-panel ::-webkit-scrollbar{width:6px;height:6px}
#bh-panel ::-webkit-scrollbar-track{background:#1a1a1a}
#bh-panel ::-webkit-scrollbar-thumb{background:#444;border-radius:3px}
#bh-panel ::-webkit-scrollbar-thumb:hover{background:#555}
#bh-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;
background:#222;border-bottom:1px solid #444;flex-shrink:0}
#bh-header h2{margin:0;font-size:17px;color:#fff}
#bh-header .bh-ver{color:#666;font-size:12px;margin-left:8px}
#bh-close{background:none;border:none;color:#999;font-size:22px;cursor:pointer;padding:4px 8px}
#bh-close:hover{color:#fff}
#bh-tabs{display:flex;background:#252525;border-bottom:1px solid #444;overflow-x:auto;flex-shrink:0}
.bh-tab{padding:10px 20px;cursor:pointer;color:#999!important;border-bottom:2px solid transparent;
white-space:nowrap;font-size:14px;transition:all .15s}
.bh-tab:hover{color:#ccc!important;background:#2a2a2a}
.bh-tab.active{color:#ef5350!important;border-bottom-color:#ef5350}
#bh-content{padding:16px;overflow-y:auto;flex:1;min-height:0}
#bh-status-line{display:flex;gap:12px;align-items:center;margin-bottom:12px;color:#888;font-size:12px;flex-wrap:wrap}
#bh-status-line .bh-err{color:#ef5350}
.bh-rl-banner{background:#2a1414;border:1px solid #ef5350;border-left:4px solid #ef5350;border-radius:4px;padding:10px 14px;margin:0 0 12px;color:#ddd;font-size:13px}
.bh-rl-title{color:#ef5350;font-weight:700;font-size:13px;margin-bottom:6px}
.bh-rl-body{color:#ccc;line-height:1.45;font-size:12px}
.bh-rl-tips{margin:6px 0 6px 20px;padding:0;color:#ddd}
.bh-rl-tips li{margin:2px 0}
.bh-rl-hint{color:#888;font-size:11px;font-style:italic}
#bh-refresh-btn{padding:4px 10px;background:#333;border:1px solid #444;color:#ddd;border-radius:4px;cursor:pointer;font-size:12px}
#bh-refresh-btn:hover{background:#3a3a3a}
#bh-refresh-btn:disabled{opacity:.5;cursor:not-allowed}
table.bh-table{width:100%;border-collapse:collapse}
.bh-table th,.bh-table td{padding:8px 14px;text-align:left;border-bottom:1px solid #333;font-size:13px;
color:#ddd;vertical-align:middle;white-space:nowrap}
.bh-table th{color:#999!important;font-weight:600;text-transform:uppercase;font-size:11px;position:sticky;
top:0;background:#1a1a1a;border-bottom:2px solid #444;user-select:none}
.bh-table th[data-sort]{cursor:pointer}
.bh-table th[data-sort]:hover{color:#fff!important}
.bh-table th[data-sort]::after{content:" ⇅";color:#555;font-size:10px}
.bh-table th[data-sort].sort-asc::after{content:" ▲";color:#ef5350;font-size:10px}
.bh-table th[data-sort].sort-desc::after{content:" ▼";color:#ef5350;font-size:10px}
.bh-table tbody tr:hover td{background:#252525}
.bh-table td.num,.bh-table th.num{text-align:right;font-variant-numeric:tabular-nums}
.bh-table td.bh-col-divider,.bh-table th.bh-col-divider{padding-left:18px;border-left:1px solid #2a2a2a}
.bh-attack{display:inline-block;padding:4px 10px;background:#ef5350;color:#fff!important;border-radius:4px;
text-decoration:none;font-weight:600;font-size:12px;border:none;cursor:pointer}
.bh-attack:hover{background:#f44336}
.bh-name-link{color:#4fc3f7!important;text-decoration:none}
.bh-name-link:hover{text-decoration:underline}
.bh-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.bh-badge-ok{background:#1e3a2a;color:#4caf50}
.bh-badge-hosp{background:#3a2a1e;color:#ffb74d}
.bh-count{display:inline-block;padding:1px 6px;margin-left:6px;background:#3a2a4a;color:#c49cff;border-radius:8px;font-size:10px;font-weight:700;vertical-align:middle}
.bh-empty{text-align:center;color:#888;padding:30px 8px;font-size:14px}
.bh-empty button{margin-top:10px}
.bh-section{margin-bottom:20px}
.bh-section h3{margin:0 0 8px;font-size:14px;color:#eee}
.bh-section p,.bh-hint{color:#888;font-size:12px;margin:4px 0}
.bh-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.bh-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.bh-field label{color:#bbb;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
.bh-input,.bh-select{background:#252525;border:1px solid #444;color:#ddd;padding:6px 10px;border-radius:4px;font-size:14px;width:100%}
/* Visual masking for API-key inputs. We intentionally avoid type="password"
because Chrome/Safari then prompt to save the value as a login credential
and password managers inject auto-fill UI on top. CSS masking gives the
bullet-dot look without the browser treating the field as a login. */
.bh-input-masked{-webkit-text-security:disc;text-security:disc;font-family:monospace;letter-spacing:2px}
.bh-btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:600;color:#ddd}
.bh-btn-primary{background:#4fc3f7;color:#111!important}.bh-btn-primary:hover{background:#29b6f6}
.bh-btn-danger{background:#ef5350;color:#fff!important}.bh-btn-danger:hover{background:#f44336}
.bh-btn-muted{background:#333;border:1px solid #444;color:#ddd}.bh-btn-muted:hover{background:#3a3a3a}
.bh-btn:disabled{opacity:.5;cursor:not-allowed}
.bh-row-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.bh-check{display:inline-flex;align-items:center;gap:8px;cursor:pointer;color:#ccc}
.bh-check input{accent-color:#ef5350}
.bh-save-status{margin-top:8px;min-height:1em;font-size:12px}
.bh-save-ok{color:#4caf50}
.bh-save-err{color:#ef5350}
.bh-save-info{color:#bbb}
.bh-debug-log{margin-top:8px;max-height:220px;overflow-y:auto;background:#111;border:1px solid #333;border-radius:4px;padding:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5}
.bh-debug-hint{color:#666;font-size:11px}
.bh-debug-row{padding:1px 0;color:#aaa;word-break:break-all}
.bh-debug-ts{color:#666;margin-right:4px}
.bh-debug-ms{color:#4caf50;margin-left:4px}
.bh-debug-ok{color:#aaa}
.bh-debug-info{color:#bbb}
.bh-debug-warn{color:#ffb74d}
.bh-debug-err{color:#ef5350}
/* Auth screen */
#bh-auth{padding:24px;color:#ddd;line-height:1.5}
#bh-auth h3{margin:0 0 12px;color:#fff}
#bh-auth .bh-auth-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
/* Toasts */
#bh-toasts{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column-reverse;gap:8px;
z-index:2147483646;pointer-events:none;max-width:calc(100vw - 32px)}
.bh-toast{pointer-events:auto;width:300px;max-width:100%;background:#1e1e1e;border:1px solid #444;border-left:4px solid #ef5350;
border-radius:8px;padding:10px 12px;color:#ddd;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.5);position:relative;cursor:pointer;
animation:bh-toast-in .2s ease-out}
@keyframes bh-toast-in{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:translateX(0)}}
.bh-toast:hover{border-color:#666}
.bh-toast-close{position:absolute;top:4px;right:6px;background:none;border:none;color:#777;font-size:18px;
cursor:pointer;padding:0 4px;line-height:1}
.bh-toast-close:hover{color:#fff}
.bh-toast-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:0 18px 4px 0}
.bh-toast-name{color:#fff;font-weight:600;overflow:hidden;text-overflow:ellipsis}
.bh-toast-lvl{color:#888;font-size:11px;font-weight:400;margin-left:4px}
.bh-toast-reward{color:#4caf50;font-weight:700;font-size:15px;white-space:nowrap}
.bh-toast-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.bh-chip{background:#2a2a2a;border:1px solid #3a3a3a;padding:2px 8px;border-radius:10px;font-size:11px;color:#bbb}
.bh-chip.bh-badge-ok{background:#1e3a2a;color:#4caf50;border-color:#1e3a2a}
.bh-chip.bh-badge-hosp{background:#3a2a1e;color:#ffb74d;border-color:#3a2a1e}
.bh-toast-attack{width:100%;padding:6px 10px;background:#ef5350;color:#fff;border:none;border-radius:4px;
cursor:pointer;font-weight:600;font-size:12px}
.bh-toast-attack:hover{background:#f44336}
.bh-toast-more{border-left-color:#888;text-align:center}
.bh-toast-more-label{color:#fff;font-weight:700;font-size:16px}
.bh-toast-more-hint{color:#888;font-size:11px;margin-top:2px}
.bh-toast-clear-btn{pointer-events:auto;align-self:flex-end;background:rgba(30,30,30,0.9);color:#aaa;
border:1px solid #444;border-radius:14px;padding:3px 10px;font-size:11px;font-weight:600;
letter-spacing:.5px;text-transform:uppercase;cursor:pointer;font-family:inherit;
box-shadow:0 2px 6px rgba(0,0,0,.4)}
.bh-toast-clear-btn:hover{background:#2a2a2a;color:#fff;border-color:#666}
@media(max-width:768px){
#bh-panel{width:100vw!important;max-width:100vw;min-width:0;border-radius:0;top:0;left:0;
transform:none;max-height:100vh;height:100vh}
.bh-grid-2{grid-template-columns:1fr}
.bh-table td,.bh-table th{padding:6px 6px;font-size:12px}
#bh-toasts{left:8px;right:8px;bottom:8px;align-items:stretch}
.bh-toast{width:auto}
}
`;
document.head.appendChild(style);
}
// ════════════════════════════════════════════════════════════
// UI
// ════════════════════════════════════════════════════════════
class UI {
constructor(hunter, toaster) {
this.hunter = hunter;
this.toaster = toaster;
this.activeTab = "hunt";
this._countdownTimer = null;
this._panel = null;
this._overlay = null;
this._authed = false;
this._sortCol = "reward";
this._sortDir = "desc";
}
inject() {
injectCSS();
const overlay = document.createElement("div");
overlay.id = "bh-overlay";
document.body.appendChild(overlay);
const panel = document.createElement("div");
panel.id = "bh-panel";
panel.innerHTML = `
<div id="bh-header">
<h2>Bounty Hunter <span class="bh-ver">v${VERSION}</span>
<span style="color:#888;font-size:11px;font-weight:400;margin-left:10px">
Like the script? Send a Xanax to
<a href="https://www.torn.com/profiles.php?XID=4192025" target="_blank"
style="color:#cc3333;text-decoration:none">eugene_s [4192025]</a>
</span>
</h2>
<button id="bh-close">×</button>
</div>
<div id="bh-tabs">
<div class="bh-tab active" data-tab="hunt">Hunt</div>
<div class="bh-tab" data-tab="settings">Settings</div>
</div>
<div id="bh-content"></div>
`;
document.body.appendChild(panel);
this._panel = panel;
this._overlay = overlay;
overlay.addEventListener("click", () => this.toggle(false));
panel.querySelector("#bh-close").addEventListener("click", () => this.toggle(false));
panel.querySelector("#bh-tabs").addEventListener("click", (e) => {
const t = e.target.closest(".bh-tab");
if (!t) return;
this.activeTab = t.dataset.tab;
this._syncTabs();
this._renderActive();
});
// Hook hunter updates → UI refresh.
this.hunter.onUpdate = () => { if (this._isOpen()) this._renderActive(); };
this.hunter.onToast = (bounties) => this.toaster.showMany(bounties);
// Countdown ticker — updates both the "next refresh in Xs" header
// and any live hospital-countdown badges (rows + toasts). Ticks
// every second; toasts live outside the panel so they tick too.
this._countdownTimer = setInterval(() => {
if (this._isOpen() && this.activeTab === "hunt") {
const el = document.getElementById("bh-countdown");
if (el) el.textContent = this.hunter.secondsUntilRefresh() + "s";
}
document.querySelectorAll("[data-hosp-until]").forEach((el) => {
const until = parseInt(el.dataset.hospUntil, 10);
if (!Number.isFinite(until)) return;
el.textContent = fmt.hospLabel(until);
});
}, 1000);
}
_isOpen() { return this._panel && this._panel.classList.contains("bh-open"); }
toggle(open) {
const isOpen = open != null ? open : !this._isOpen();
this._panel.classList.toggle("bh-open", isOpen);
this._overlay.classList.toggle("bh-open", isOpen);
if (isOpen) this._renderActive();
}
_syncTabs() {
for (const t of this._panel.querySelectorAll(".bh-tab")) {
t.classList.toggle("active", t.dataset.tab === this.activeTab);
}
}
_renderActive() {
if (!this._authed) { this._renderAuth(); return; }
if (this.activeTab === "hunt") this._renderHunt();
else this._renderSettings();
}
setAuthed(b) {
this._authed = b;
if (this._isOpen()) this._renderActive();
}
_renderAuth() {
const content = this._panel.querySelector("#bh-content");
const spaAvailable = !IS_PDA && KeyResolver.hasSPAKey();
content.innerHTML = `
<div id="bh-auth">
<h3>Set your Torn API key</h3>
<p>Bounty Hunter needs a <b>public</b> Torn API key to read the global bounty board, each target's status, and your own user ID. The key is stored in this browser's localStorage and used only against <code>api.torn.com</code>.</p>
<p class="bh-hint">Get one at <a class="bh-name-link" href="https://www.torn.com/preferences.php#tab=api" target="_blank" rel="noopener">torn.com → Preferences → API Key</a>. "Public" access is sufficient.</p>
<div class="bh-field">
<label>Torn API key (16 chars)</label>
<input id="bh-auth-key" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<div class="bh-auth-actions">
<button id="bh-auth-save" class="bh-btn bh-btn-primary">Save & start</button>
${spaAvailable ? `<button id="bh-auth-spa" class="bh-btn bh-btn-muted">Use Supply Pack Analyzer's saved key</button>` : ""}
</div>
<p class="bh-hint" id="bh-auth-err" style="color:#ef5350;min-height:1em"></p>
<hr style="border:none;border-top:1px solid #333;margin:16px 0">
<h3>Optional: FFScouter key</h3>
<p>FFScouter provides the fair-fight score. Without it Bounty Hunter can't filter targets by FF — which is the whole point of the script.</p>
<p class="bh-hint">Get one at <a class="bh-name-link" href="https://ffscouter.com" target="_blank" rel="noopener">ffscouter.com</a>. You can set this later under Settings.</p>
<div class="bh-field">
<label>FFScouter key (16 chars)</label>
<input id="bh-auth-ffkey" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" value="${escHtml(KeyResolver.getFFKey())}">
</div>
</div>
`;
const keyInput = content.querySelector("#bh-auth-key");
const err = content.querySelector("#bh-auth-err");
const save = async () => {
err.textContent = "";
const k = keyInput.value.trim();
if (!/^[A-Za-z0-9]{16}$/.test(k)) {
err.textContent = "That doesn't look like a 16-character Torn key.";
return;
}
await this._adoptKey(k, content.querySelector("#bh-auth-ffkey").value.trim());
};
content.querySelector("#bh-auth-save").addEventListener("click", save);
keyInput.addEventListener("keydown", (e) => { if (e.key === "Enter") save(); });
const spaBtn = content.querySelector("#bh-auth-spa");
if (spaBtn) {
spaBtn.addEventListener("click", async () => {
err.textContent = "";
const k = KeyResolver.getSPAKey();
if (!k) { err.textContent = "Supply Pack Analyzer's key isn't available."; return; }
await this._adoptKey(k, content.querySelector("#bh-auth-ffkey").value.trim());
});
}
}
async _adoptKey(tornKey, ffKey) {
const err = this._panel.querySelector("#bh-auth-err");
if (err) err.textContent = "Validating…";
this.hunter.api.setKey(tornKey);
try {
await this.hunter.api.validateKey();
} catch (e) {
if (err) err.textContent = "Torn rejected that key: " + (e.message || "invalid");
return;
}
KeyResolver.saveTornKey(tornKey);
if (ffKey && /^[A-Za-z0-9]{16}$/.test(ffKey)) KeyResolver.saveFFKey(ffKey);
this.setAuthed(true);
this.hunter.start();
}
_renderHunt() {
const content = this._panel.querySelector("#bh-content");
const rows = this._sortMatches(this.hunter.lastMatches);
const c = this.hunter.lastCounts;
const nextIn = this.hunter.secondsUntilRefresh();
const rateLimited = isRateLimitError(this.hunter.lastError);
// Only show the generic error line when it isn't a rate-limit —
// the rate-limit case gets its own banner below with actionable copy.
const errLine = (this.hunter.lastError && !rateLimited)
? `<span class="bh-err">${escHtml(this.hunter.lastError.message || "error")}</span>`
: "";
const ffKeyMissing = !KeyResolver.getFFKey() && !this.hunter.settings.includeUnknownFF
? `<span class="bh-err">No FFScouter key set — every target will be excluded. Add one in Settings, or enable "Include unknown-FF targets".</span>`
: "";
const ffError = c && c.ffError
? `<span class="bh-err">FFScouter: ${escHtml(c.ffError)}</span>`
: "";
const thCls = (col) => {
if (this._sortCol !== col) return "";
return this._sortDir === "asc" ? "sort-asc" : "sort-desc";
};
// Rate-limit banner — prominent, actionable. When prior matches
// exist we keep them on screen; when they don't, the banner fully
// replaces the misleading "no matches" empty state.
const rateLimitTitle = rows.length > 0
? "Rate limit hit — showing previous results"
: "Rate limit hit — no results to show";
const rateLimitLead = rows.length > 0
? `Torn / FFScouter returned a rate-limit response while processing ${c && c.total != null ? c.total + " " : ""}bounties. The table below is from the last successful refresh and may be slightly stale.`
: `Torn / FFScouter returned a rate-limit response before any matches could be confirmed this cycle. No table is shown because no successful refresh has completed yet.`;
const rateLimitBanner = rateLimited
? `
<div class="bh-rl-banner">
<div class="bh-rl-title">${rateLimitTitle}</div>
<div class="bh-rl-body">
${rateLimitLead}
<br><br>
<b>To reduce API load, tighten your filters so fewer targets need a per-profile lookup:</b>
<ul class="bh-rl-tips">
<li>Raise <b>Min reward</b> (e.g. $1M+) — skips low-value bounties.</li>
<li>Narrow <b>Fair-fight</b> range — excludes targets outside your combat bracket.</li>
<li>Lower <b>Hospital max</b> — skips long-hospital targets that aren't actionable.</li>
<li>Increase <b>Auto-refresh</b> interval (e.g. 2 min+) — spreads calls over time.</li>
</ul>
<span class="bh-rl-hint">Auto-retry will continue in the background.</span>
</div>
</div>
`
: "";
content.innerHTML = `
<div id="bh-status-line">
<button id="bh-refresh-btn" class="bh-btn-muted">Refresh now</button>
<span>Next refresh in <span id="bh-countdown">${nextIn}</span>s</span>
<span>${rows.length} match${rows.length === 1 ? "" : "es"}</span>
${errLine}
${ffKeyMissing}
${ffError}
</div>
${rateLimitBanner}
${rows.length === 0 ? (rateLimited ? "" : `
<div class="bh-empty">
No bounties match your filters right now.<br>
<span style="color:#666">Adjust min price, FF range, or hospital window under Settings.</span>
</div>
`) : `
<table class="bh-table">
<thead>
<tr>
<th data-sort="target" class="${thCls("target")}">Target</th>
<th data-sort="reward" class="num ${thCls("reward")}">Reward</th>
<th data-sort="ff" class="num ${thCls("ff")}">FF</th>
<th data-sort="bs" class="num bh-col-divider ${thCls("bs")}">BS</th>
<th data-sort="status" class="bh-col-divider ${thCls("status")}">Status</th>
<th></th>
</tr>
</thead>
<tbody>
${rows.map((row) => this._renderRow(row)).join("")}
</tbody>
</table>
`}
`;
const btn = content.querySelector("#bh-refresh-btn");
btn.addEventListener("click", async () => {
btn.disabled = true;
try { await this.hunter.refresh(); } catch { /* shown in status line */ }
btn.disabled = false;
});
content.querySelectorAll(".bh-attack").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
const id = el.dataset.id;
openTornUrl(ATTACK_URL + id);
});
});
content.querySelectorAll("th[data-sort]").forEach((el) => {
el.addEventListener("click", () => {
const col = el.dataset.sort;
if (this._sortCol === col) {
this._sortDir = this._sortDir === "asc" ? "desc" : "asc";
} else {
this._sortCol = col;
// Numeric columns default to descending (biggest first),
// text columns default to ascending (A–Z).
this._sortDir = (col === "target") ? "asc" : "desc";
}
this._renderHunt();
});
});
}
_sortMatches(list) {
const col = this._sortCol;
const dir = this._sortDir === "asc" ? 1 : -1;
const statusRank = (m) => m.statusState === "Okay" ? 0 : 1;
const keyFor = (m) => {
switch (col) {
case "target": return String(m.target_name || "").toLowerCase();
case "reward": return m.reward || 0;
case "ff": return m.ff == null ? Number.POSITIVE_INFINITY : m.ff;
case "bs": return parseBS(m.bs) == null ? Number.POSITIVE_INFINITY : parseBS(m.bs);
case "status": {
// Okay first (desc) or last (asc); within Hospital, sort by time-remaining.
const remain = m.statusState === "Hospital"
? Math.max(0, (m.hospUntil || 0) - Math.floor(Date.now() / 1000))
: 0;
return statusRank(m) * 1e6 + remain;
}
default: return m.reward || 0;
}
};
// "Infinity" keys (missing data) stay at the end regardless of dir,
// so the user isn't bombarded with blank cells on asc.
return [...list].sort((a, b) => {
const ka = keyFor(a); const kb = keyFor(b);
const aInf = ka === Number.POSITIVE_INFINITY;
const bInf = kb === Number.POSITIVE_INFINITY;
if (aInf && !bInf) return 1;
if (bInf && !aInf) return -1;
if (ka < kb) return -1 * dir;
if (ka > kb) return 1 * dir;
// Secondary: reward desc, keeps the big ones on top within ties.
return (b.reward || 0) - (a.reward || 0);
});
}
_renderRow(b) {
const statusClass = b.statusState === "Hospital" ? "bh-badge-hosp" : "bh-badge-ok";
const hospAttr = b.statusState === "Hospital" ? ` data-hosp-until="${b.hospUntil}"` : "";
const statusText = b.statusState === "Hospital" ? fmt.hospLabel(b.hospUntil) : "Okay";
const ffCell = b.ff == null ? "—" : b.ff.toFixed(2);
const countBadge = b.bountyCount > 1
? ` <span class="bh-count">×${b.bountyCount}</span>`
: "";
return `
<tr>
<td>
<a class="bh-name-link" href="${PROFILE_URL}${b.target_id}" target="_blank" rel="noopener">${escHtml(b.target_name)}</a>
<span style="color:#666;font-size:11px"> L${b.target_level}</span>${countBadge}
</td>
<td class="num">${escHtml(fmt.moneyFull(b.reward))}</td>
<td class="num">${ffCell}</td>
<td class="num bh-col-divider">${escHtml(b.bs || "—")}</td>
<td class="bh-col-divider"><span class="bh-badge ${statusClass}"${hospAttr}>${statusText}</span></td>
<td><button class="bh-attack" data-id="${b.target_id}">Attack →</button></td>
</tr>
`;
}
_renderSettings() {
const content = this._panel.querySelector("#bh-content");
const s = this.hunter.settings;
const tornKey = KeyResolver.resolveTornKey() || "";
const pdaNote = KeyResolver.isPDAKey()
? `<p class="bh-hint">Your Torn key is provided by Torn PDA. It is not editable here.</p>`
: "";
content.innerHTML = `
<div class="bh-section">
<h3>Filters</h3>
<div class="bh-grid-2">
<div class="bh-field">
<label>Min reward ($)</label>
<input id="bh-set-price" class="bh-input" type="number" min="0" step="10000" value="${s.minPrice}">
</div>
<div class="bh-field">
<label>Hospital max (minutes remaining)</label>
<input id="bh-set-hosp" class="bh-input" type="number" min="0" max="60" step="1" value="${s.hospitalMaxMin}">
<span class="bh-hint">0 = Okay only. ~5 lets you queue targets about to leave hospital.</span>
</div>
<div class="bh-field">
<label>Fair-fight min</label>
<input id="bh-set-ffmin" class="bh-input" type="number" min="1" max="10" step="0.1" value="${s.minFF}">
</div>
<div class="bh-field">
<label>Fair-fight max</label>
<input id="bh-set-ffmax" class="bh-input" type="number" min="1" max="10" step="0.1" value="${s.maxFF}">
</div>
</div>
</div>
<div class="bh-section">
<h3>Refresh</h3>
<div class="bh-grid-2">
<div class="bh-field">
<label>Auto-refresh</label>
<select id="bh-set-refresh" class="bh-select">
<option value="30"${s.refreshSec === 30 ? " selected" : ""}>Every 30 s</option>
<option value="60"${s.refreshSec === 60 ? " selected" : ""}>Every 60 s</option>
<option value="120"${s.refreshSec === 120 ? " selected" : ""}>Every 2 min</option>
<option value="300"${s.refreshSec === 300 ? " selected" : ""}>Every 5 min</option>
<option value="0"${s.refreshSec === 0 ? " selected" : ""}>Off (manual only)</option>
</select>
<span class="bh-hint">Honors Torn's global bounty-cache delay. 60 s is comfortably under the rate limit.</span>
</div>
<div class="bh-field">
<label>Notifications</label>
<label class="bh-check"><input type="checkbox" id="bh-set-toasts"${s.toastsEnabled ? " checked" : ""}> Show toast for new matches</label>
<label class="bh-check"><input type="checkbox" id="bh-set-unkff"${s.includeUnknownFF ? " checked" : ""}> Include targets with unknown FF score</label>
</div>
</div>
</div>
<div class="bh-section">
<h3>Torn API key</h3>
${pdaNote}
<div class="bh-field">
<input id="bh-set-tornkey" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" value="${escHtml(tornKey)}" ${KeyResolver.isPDAKey() ? "disabled" : ""}>
</div>
${!KeyResolver.isPDAKey() ? `
<div class="bh-row-actions">
<button id="bh-key-save" class="bh-btn bh-btn-primary">Save Torn key</button>
<button id="bh-key-clear" class="bh-btn bh-btn-danger">Clear Torn key & log out</button>
</div>
<div id="bh-key-status" class="bh-save-status"></div>
` : ""}
</div>
<div class="bh-section">
<h3>FFScouter key</h3>
<p class="bh-hint">Used to fetch fair-fight scores in bulk (one call per refresh).</p>
<div class="bh-field">
<input id="bh-set-ffkey" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" value="${escHtml(KeyResolver.getFFKey())}">
</div>
<div class="bh-row-actions">
<button id="bh-ffkey-save" class="bh-btn bh-btn-primary">Save FFScouter key</button>
<button id="bh-ffkey-clear" class="bh-btn bh-btn-muted">Clear</button>
</div>
<div id="bh-ffkey-status" class="bh-save-status"></div>
</div>
<div class="bh-section">
<h3>Debug log</h3>
<label class="bh-check">
<input type="checkbox" id="bh-set-debug"${isDebugOn() ? " checked" : ""}> Record API requests & rate-limit hits
</label>
<p class="bh-hint">When on, every Torn / FFScouter request is logged below with its status and latency. Rate-limit hits (Torn code 5, HTTP 429) are highlighted. Use this to confirm whether a slow refresh is rate-limited or just the network.</p>
<div id="bh-debug-log" class="bh-debug-log"></div>
<div class="bh-row-actions" style="margin-top:6px">
<button id="bh-debug-clear" class="bh-btn bh-btn-muted">Clear log</button>
</div>
</div>
<div class="bh-section">
<h3>Environment</h3>
<p class="bh-hint">Version ${VERSION} · Platform: ${IS_PDA ? "Torn PDA" : "Desktop"}</p>
<button id="bh-reset" class="bh-btn bh-btn-muted">Reset filters to defaults</button>
</div>
`;
const $ = (id) => content.querySelector("#" + id);
const persistFilters = () => {
const minFF = parseFloat($("bh-set-ffmin").value);
const maxFF = parseFloat($("bh-set-ffmax").value);
const cleanMin = Number.isFinite(minFF) ? Math.max(1, minFF) : 1.0;
const cleanMax = Number.isFinite(maxFF) ? Math.max(cleanMin, maxFF) : cleanMin;
this.hunter.updateSettings({
minPrice: Math.max(0, parseInt($("bh-set-price").value, 10) || 0),
hospitalMaxMin: Math.max(0, Math.min(60, parseInt($("bh-set-hosp").value, 10) || 0)),
minFF: cleanMin,
maxFF: cleanMax,
refreshSec: parseInt($("bh-set-refresh").value, 10),
toastsEnabled: $("bh-set-toasts").checked,
includeUnknownFF: $("bh-set-unkff").checked,
});
if (this.hunter.settings.refreshSec > 0) {
this.hunter.stop();
this.hunter.start();
} else {
this.hunter.stop();
}
};
["bh-set-price", "bh-set-hosp", "bh-set-ffmin", "bh-set-ffmax", "bh-set-refresh", "bh-set-toasts", "bh-set-unkff"]
.forEach((id) => $(id).addEventListener("change", persistFilters));
const setStatus = (elId, kind, msg) => {
const el = $(elId);
if (!el) return;
el.className = "bh-save-status bh-save-" + kind;
el.textContent = msg;
};
if (!KeyResolver.isPDAKey()) {
$("bh-key-save").addEventListener("click", async () => {
const k = $("bh-set-tornkey").value.trim();
if (!/^[A-Za-z0-9]{16}$/.test(k)) {
setStatus("bh-key-status", "err", "Torn key must be 16 alphanumeric characters.");
return;
}
setStatus("bh-key-status", "info", "Validating…");
this.hunter.api.setKey(k);
try {
await this.hunter.api.validateKey();
} catch (e) {
setStatus("bh-key-status", "err", "Key rejected: " + (e.message || "invalid"));
return;
}
KeyResolver.saveTornKey(k);
this.hunter.myUserId = null;
this.hunter.myUserLevel = null;
this.hunter.myUserAge = null;
setStatus("bh-key-status", "ok", "Saved. Refreshing matches…");
await this.hunter.refresh().catch(() => {});
setStatus("bh-key-status", "ok", "Saved ✓");
});
$("bh-key-clear").addEventListener("click", () => {
if (!confirm("Clear the Torn API key and return to the auth screen?")) return;
KeyResolver.clearTornKey();
this.hunter.stop();
this.toaster.clearAll();
this.setAuthed(false);
});
}
$("bh-ffkey-save").addEventListener("click", async () => {
const k = $("bh-set-ffkey").value.trim();
if (!k) {
KeyResolver.clearFFKey();
setStatus("bh-ffkey-status", "info", "Cleared.");
// Drop stale error so Hunt tab doesn't display a ghost message.
if (this.hunter.lastCounts) this.hunter.lastCounts.ffError = null;
this.hunter.refresh().catch(() => {});
return;
}
setStatus("bh-ffkey-status", "info", "Validating with FFScouter…");
const result = await validateFFScouterKey(k);
if (!result.ok) {
setStatus("bh-ffkey-status", "err", result.message);
return;
}
KeyResolver.saveFFKey(k);
// Clear any "no_key" / "Invalid API key" error left over from the
// previous refresh, so tab-switching to Hunt before the new
// refresh lands doesn't show a ghost FFScouter error.
if (this.hunter.lastCounts) this.hunter.lastCounts.ffError = null;
setStatus("bh-ffkey-status", "ok", result.message + " Refreshing matches…");
await this.hunter.refresh().catch(() => {});
setStatus("bh-ffkey-status", "ok", result.message + " Saved ✓");
});
$("bh-ffkey-clear").addEventListener("click", () => {
KeyResolver.clearFFKey();
$("bh-set-ffkey").value = "";
if (this.hunter.lastCounts) this.hunter.lastCounts.ffError = null;
setStatus("bh-ffkey-status", "info", "Cleared.");
this.hunter.refresh().catch(() => {});
});
$("bh-reset").addEventListener("click", () => {
if (!confirm("Reset filters to defaults?")) return;
this.hunter.updateSettings({ ...DEFAULT_SETTINGS, toastsEnabled: this.hunter.settings.toastsEnabled });
this._renderActive();
});
// Debug log section — checkbox persists, log area updates live.
const debugToggle = $("bh-set-debug");
const debugEl = $("bh-debug-log");
const renderLog = () => {
if (!debugEl.isConnected) return;
if (!isDebugOn()) {
debugEl.innerHTML = `<span class="bh-debug-hint">Debug is off. Enable it to start recording.</span>`;
return;
}
if (debugLog.length === 0) {
debugEl.innerHTML = `<span class="bh-debug-hint">No events yet — trigger a refresh from the Hunt tab.</span>`;
return;
}
const rows = [...debugLog].reverse().map((r) => {
const msPart = (r.ms != null) ? ` <span class="bh-debug-ms">${r.ms}ms</span>` : "";
return `<div class="bh-debug-row bh-debug-${r.level}"><span class="bh-debug-ts">${r.ts}</span> ${escHtml(r.label)}${msPart}</div>`;
}).join("");
debugEl.innerHTML = rows;
};
debugToggle.addEventListener("change", () => {
try { localStorage.setItem(LS.debug, debugToggle.checked ? "1" : ""); } catch {}
if (!debugToggle.checked) debugLog.length = 0;
renderLog();
});
$("bh-debug-clear").addEventListener("click", () => {
debugLog.length = 0;
renderLog();
});
// Live updates: subscribe once per render, and clean up when the
// node goes away (next settings re-render replaces the container).
const onEntry = () => renderLog();
debugBus.addEventListener("entry", onEntry);
const detach = new MutationObserver(() => {
if (!debugEl.isConnected) {
debugBus.removeEventListener("entry", onEntry);
detach.disconnect();
}
});
detach.observe(this._panel, { childList: true, subtree: true });
renderLog();
}
}
// ════════════════════════════════════════════════════════════
// Shared footer menu (eugene-torn-scripts userscripts)
// — 1 script installed: its icon goes in the footer directly.
// — 2+ installed: a single 3-dots menu holds them all and
// expands a row above the footer on click.
// Idempotent and duplicated verbatim across scripts. The
// __eugFooterMenuLoaded guard ensures setup runs once per page.
// ════════════════════════════════════════════════════════════
(function setupEugFooterMenu() {
// Use the page's real window so scripts in different @grant sandboxes
// share the same registry. SPA (@grant none) and TAT (@grant GM_*)
// otherwise see isolated `window` objects and can't find each other.
const W = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
if (W.__eugFooterMenuLoaded) return;
W.__eugFooterMenuLoaded = true;
W.__eugeneScripts = W.__eugeneScripts || [];
const ROW_ID = "eug-footer-row";
function injectCSS() {
if (document.getElementById("eug-footer-style")) return;
const style = document.createElement("style");
style.id = "eug-footer-style";
style.textContent = `
[data-eug="menu"]{background:linear-gradient(to bottom,#444,#2a2a2a)!important}
[data-eug="menu"]:hover{background:linear-gradient(to bottom,#555,#333)!important}
#${ROW_ID}{display:none;position:fixed;padding:4px;
background:rgba(20,20,20,0.96);border:1px solid #444;border-radius:6px;
gap:4px;z-index:2147483647;white-space:nowrap;pointer-events:auto}
#${ROW_ID}.eug-open{display:flex;flex-direction:row}
`;
document.head.appendChild(style);
}
function injectEntryCSS(entry) {
if (!entry.color) return;
const id = `eug-color-${entry.id}`;
const existing = document.getElementById(id);
const dark = entry.colorDark || "#222";
const hover = entry.hoverColor || entry.color;
const css = `
[data-eug-id="${entry.id}"]{background:linear-gradient(to bottom, ${entry.color}, ${dark})!important}
[data-eug-id="${entry.id}"]:hover{background:linear-gradient(to bottom, ${hover}, ${entry.color})!important}
`;
if (existing) { existing.textContent = css; return; }
const el = document.createElement("style");
el.id = id;
el.textContent = css;
document.head.appendChild(el);
}
function findRefBtn() {
return document.getElementById("notes_panel_button")
|| document.getElementById("people_panel_button");
}
function getRow() { return document.getElementById(ROW_ID); }
function closeRow() { const r = getRow(); if (r) r.classList.remove("eug-open"); }
function openRow(menuBtn) {
const row = getRow();
if (!row) return;
const rect = menuBtn.getBoundingClientRect();
row.classList.add("eug-open");
const rowRect = row.getBoundingClientRect();
const gap = 6;
const centerX = rect.left + rect.width / 2;
let left = centerX - rowRect.width / 2;
const maxLeft = window.innerWidth - rowRect.width - 4;
left = Math.max(4, Math.min(left, maxLeft));
row.style.left = left + "px";
row.style.bottom = (window.innerHeight - rect.top + gap) + "px";
}
function makeScriptBtn(entry, refBtn, role) {
const iconClasses = refBtn.querySelector("svg")?.className?.baseVal || "";
const btn = document.createElement("button");
btn.type = "button";
btn.className = refBtn.className;
btn.title = entry.name;
btn.setAttribute("data-eug", role);
btn.setAttribute("data-eug-id", entry.id);
const svg = (entry.iconSVG || "").replace(/<svg\b([^>]*)>/, (match, attrs) =>
/\sclass\s*=/.test(attrs) ? match : `<svg${attrs} class="${iconClasses}">`);
btn.innerHTML = svg;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeRow();
try { entry.onClick(); } catch { /* noop */ }
});
injectEntryCSS(entry);
return btn;
}
function makeMenuBtn(refBtn) {
const iconClasses = refBtn.querySelector("svg")?.className?.baseVal || "";
const btn = document.createElement("button");
btn.type = "button";
btn.className = refBtn.className;
btn.title = "My userscripts";
btn.setAttribute("data-eug", "menu");
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="${iconClasses}">
<defs><linearGradient id="eug_menu_grad" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#ddd"/><stop offset="1" stop-color="#999"/>
</linearGradient></defs>
<g fill="url(#eug_menu_grad)">
<circle cx="5" cy="12" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="19" cy="12" r="2"/>
</g>
</svg>`;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const row = getRow();
if (row && row.classList.contains("eug-open")) closeRow();
else openRow(btn);
});
return btn;
}
// Legacy standalone-button IDs from pre-shared-menu versions.
// If a user has a mixed install (one script new, one old), the old
// script creates its own button under one of these IDs. Nuke them
// so the shared menu stays authoritative. Safe to add new IDs here.
const LEGACY_BUTTON_IDS = ["tat-footer-btn", "spa-footer-btn"];
function render() {
const refBtn = findRefBtn();
if (!refBtn) return false;
injectCSS();
const parent = refBtn.parentNode;
parent.querySelectorAll('[data-eug]').forEach((el) => el.remove());
LEGACY_BUTTON_IDS.forEach((id) => {
const el = document.getElementById(id);
if (el) el.remove();
});
const oldRow = getRow();
if (oldRow) oldRow.remove();
const scripts = W.__eugeneScripts || [];
if (scripts.length === 0) return true;
if (scripts.length === 1) {
parent.insertBefore(makeScriptBtn(scripts[0], refBtn, "solo"), refBtn);
} else {
const menuBtn = makeMenuBtn(refBtn);
parent.insertBefore(menuBtn, refBtn);
const row = document.createElement("div");
row.id = ROW_ID;
row.setAttribute("data-eug-row", "");
for (const s of scripts) row.appendChild(makeScriptBtn(s, refBtn, "item"));
document.body.appendChild(row);
}
return true;
}
function mount() {
render();
// Torn's SPA swaps the footer DOM on navigation, taking our buttons
// with it. Keep observing indefinitely and re-render whenever the
// ref button is back but our buttons are gone. Throttled via rAF.
let pending = false;
const obs = new MutationObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const refBtn = findRefBtn();
if (refBtn && !refBtn.parentNode.querySelector('[data-eug]')) render();
});
});
obs.observe(document.body, { childList: true, subtree: true });
}
W.addEventListener("eugene-scripts-updated", render);
document.addEventListener("click", (e) => {
const row = getRow();
if (!row || !row.classList.contains("eug-open")) return;
const menuBtn = document.querySelector('[data-eug="menu"]');
if (menuBtn && menuBtn.contains(e.target)) return;
if (row.contains(e.target)) return;
closeRow();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeRow(); });
W.addEventListener("scroll", closeRow, { passive: true });
W.addEventListener("resize", closeRow);
W.registerEugeneScript = function (entry) {
const list = W.__eugeneScripts;
const i = list.findIndex((s) => s.id === entry.id);
if (i >= 0) list[i] = entry;
else list.push(entry);
W.dispatchEvent(new CustomEvent("eugene-scripts-updated"));
};
W.mountEugeneFooterMenu = mount;
})();
// ════════════════════════════════════════════════════════════
// MAIN
// ════════════════════════════════════════════════════════════
const BH_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs><linearGradient id="bh_icon_grad" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#ddd"/><stop offset="1" stop-color="#999"/>
</linearGradient></defs>
<g fill="url(#bh_icon_grad)">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8Z"/>
<path d="M12 6a6 6 0 1 0 6 6 6 6 0 0 0-6-6Zm0 10a4 4 0 1 1 4-4 4 4 0 0 1-4 4Z"/>
<circle cx="12" cy="12" r="1.5"/>
<path d="M11 0h2v5h-2zM11 19h2v5h-2zM0 11h5v2H0zM19 11h5v2h-5z"/>
</g>
</svg>`;
function main() {
const initialKey = KeyResolver.resolveTornKey();
const api = new TornAPI(initialKey || "");
const hunter = new Hunter(api);
const toaster = new Toaster();
const ui = new UI(hunter, toaster);
// Toaster needs a way to reach the UI for its "+N more" card.
window.__bhUI = ui;
ui.inject();
ui.setAuthed(!!initialKey);
// Cross-tab sync: when another tab writes fresh match data, adopt it
// here and re-render. The Hunter's own _tick path separately reads
// the same store on its next cycle to decide whether to skip its
// own fetch.
window.addEventListener("storage", (e) => {
if (e.key !== LS.shared || !e.newValue) return;
try {
const payload = JSON.parse(e.newValue);
hunter.adoptSharedPayload(payload);
} catch { /* ignore malformed writes */ }
});
const W = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
W.registerEugeneScript({
id: "bh",
name: "Bounty Hunter",
color: "#b33a3a",
colorDark: "#6b1f1f",
hoverColor: "#d64a4a",
iconSVG: BH_ICON_SVG,
onClick: () => ui.toggle(true),
});
W.mountEugeneFooterMenu();
if (initialKey) hunter.start();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}
})();