// ==UserScript== // @name ATK Scouter v2.1.12 // @namespace Violentmonkey Scripts // @match https://www.torn.com/* // @match https://pda.torn.com/* // @version 2.1.12 // @description FIXED: API counter flickering on mobile - prevented multiple setInterval timers from running simultaneously // @updateURL https://raw.githubusercontent.com/infodump01/LE-Scouter/main/LE_Scouter_Working_Prototype.js // @downloadURL https://raw.githubusercontent.com/infodump01/LE-Scouter/main/LE_Scouter_Working_Prototype.js // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @connect api.torn.com // ==/UserScript== /* * ATK SCOUTER v2.1.9 - RELEASE NOTES * * MAJOR FEATURES: * =============== * * RSI Analysis & Caching System: * - 7-day persistent RSI cache using IndexedDB * - Instant triangle indicators on faction pages * - Intelligent cache refresh based on fight history * - Massive API call reduction (saves 60-80% on reloads) * * Status Tracking System: * - Real-time hospital/traveling status indicators * - 3-minute status cache with automatic refresh * - Background timer for automatic status updates * - Never miss when opponents become available * * Smart Refresh Logic: * - LastCheck cache prevents redundant API calls * - Zero API calls on page reload (within 3-minute window) * - Background refresh every 3 minutes (automatic) * - Aligned TTLs prevent icons from disappearing * * Fight Intelligence: * - Win/loss tracking with machine learning * - Historical fight data analysis * - Probability-based recommendations * - Auto-learning from every fight * * RECENT UPDATES (v2.0.0 → v2.1.9): * ================================== * * v2.1.9 - API Counter Overlay (Current) - Option 3: * - REDESIGNED: API counter now overlays TOP PORTION of settings cog (not above it) * - Translucent glass-morphism design with gradient background * - Counter appears INSIDE the cog's top ~40% area * - Cog icon remains visible through semi-transparent overlay * - Backdrop blur effect (glass effect) for modern aesthetic * - Space saved: Maximum efficiency - 0px additional vertical space * - Clickability: Settings cog remains 100% clickable (pointer-events: none) * - Design: Counter sits at z-index 9998, cog at 10000 (icon shows through) * - Gradient fades from opaque (92%) at top to more transparent (65%) at bottom * - Rounded top matches cog's circular edge for seamless integration * - NOTE: v2.1.8 (arc style) didn't look right - this version overlays instead * * v2.1.7 - Duplicate Logging Fix - CRITICAL: * - FIXED: Wins and losses being logged twice to database * - Root cause: Script was processing BOTH attack page (sid=attack) AND attackLog page (sid=attackLog) * - When fights finished quickly, result was visible on both pages * - Both pages would detect the outcome and log it → duplicate entries * - Solution: Only process attackLog page (the official result page after fight completes) * - Removed sid=attack from isAttackResultPage() check * - Now: One page = one log entry (as intended) * - Affects: Both wins AND losses (both were duplicating) * - Testing: Confirmed on both PC and mobile (Torn PDA) * * v2.1.6 - IndexedDB Schema Fix - CRITICAL MOBILE FIX: * - FIXED: Fight Intelligence panel not appearing on mobile devices (Torn PDA) * - Root cause: IndexedDB schema corruption causing "index not found" errors * - Incremented DB_VERSION to 3 to force schema recreation on all devices * - Added proper error handling - panel now appears even if database has errors * - Database queries now fail gracefully instead of crashing the panel * - Improved database migration - deletes and recreates stores to fix corruption * - Added detailed logging for database upgrade process * - Panel will show "No data yet" message if database is broken (instead of not appearing) * - This fix resolves the mobile-specific issue where panel worked on PC but not mobile * * v2.1.3 - Mobile UI Fix: * - Moved API counter from top-right to bottom-left (below settings cog wheel) * - Improves mobile usability by preventing interference with Torn UI elements * - Counter now vertically stacked with settings button for better mobile layout * * v2.1.2 - Mobile Reload Fix: * - Fixed "Save & Reload" button not working on mobile browsers (Torn PDA) * - CRITICAL FIX: Close modal before reload (modal overlay was blocking navigation) * - Added 100ms delay to allow modal close to complete (event loop requirement) * - Primary method now uses window.open(_self) which bypasses mobile restrictions * - Shows loading indicator during reload process (visual feedback) * - Fallback message if auto-reload fails (tells user to refresh manually) * - Multiple aggressive reload methods: open(_self) → reload(true) → replace() → href * - Settings panel now properly saves and reloads on all devices * - Bust Sniper and Bounty Sniper can now be properly disabled on mobile * * v2.1.1 - TTL Alignment Fix: * - Fixed status icons disappearing between refreshes * - Extended Status Cache TTL from 60s to 180s * - Aligned Status Cache TTL with LastCheck interval (both 3 minutes) * - Status icons now display continuously without gaps * * v2.1.0 - LastCheck + Background Timer: * - Added LastCheckCacheManager to track refresh timestamps * - Implemented automatic background status refresh timer * - Timer fires every 3 minutes on faction/bounty pages * - Prevents duplicate refreshes on page reload * - Reduces reload API calls from 100 to 0 (within refresh window) * * v2.0.0 - Smart Refresh: * - Implemented separate cache checking for RSI and status * - RSI cache: 7-day TTL (battle stats change slowly) * - Status cache: 60s TTL → 180s TTL (v2.1.1) * - Lightweight status-only refresh when RSI is cached * - Eliminated duplicate status queue system * * v1.9.x - Status Cache System: * - Added StatusCacheManager with IndexedDB storage * - Selective caching (only hospital/traveling users) * - Status icons with hospital countdown timers * - Traveling indicator with destination display * * v1.8.0 - RSI Cache Introduction: * - Implemented RSICacheManager with IndexedDB * - 7-day persistent cache for battle stats * - Priority-based refresh system * - Cache statistics tracking * * v1.7.2 - Schema Fixes: * - Fixed IndexedDB schema bugs ('odefinerId' → 'opponentId') * - Fixed self-fight logging with user ID checks * - Improved loss tracking with retry logic * - Incremented DB_VERSION to 2 for schema migration * * PERFORMANCE METRICS: * ==================== * * API Call Reduction: * - First load: 100 calls (unchanged) * - Reload @ 30s: 0 calls (was 100) - 100% reduction * - Reload @ 2min: 0 calls (was 100) - 100% reduction * - Reload @ 5min: 100 lightweight calls (was 100 full calls) - 67% data reduction * - Daily usage: ~50% fewer API calls vs no caching * * Cache Hit Rates (typical): * - RSI cache: 95%+ hit rate on faction page reloads * - Status cache: 100% hit rate within 3-minute window * - Combined: Saves ~2,000+ API calls per day (active user) * * TECHNICAL DETAILS: * ================== * * Cache Architecture: * - RSI Cache: IndexedDB (persistent, 7-day TTL) * - Status Cache: IndexedDB (persistent, 3-minute TTL) * - LastCheck Cache: localStorage (persistent, 3-minute interval) * - Background Timer: setInterval (3-minute cycle) * * API Call Types: * - Full RSI: /user/{id}?selections=personalstats,basic,profile * - Status Only: /user/{id}?selections=profile (lightweight) * - Rate Limiting: 3 concurrent max, 100ms delay between calls * * Cache Tiers (RSI): * - FRESH: <2 days (high confidence) * - MODERATE: 2-5 days (medium confidence, queued for refresh) * - STALE: 5-7 days (low confidence, queued for refresh) * - EXPIRED: >7 days (deleted, requires fresh fetch) * * KNOWN LIMITATIONS: * ================== * - Background timer only works on faction/bounty pages * - Status cache requires active status (hospital/traveling) * - RSI cache disabled if user disables in settings * - localStorage/IndexedDB required (not available in private mode) * * FUTURE ROADMAP: * =============== * - Configurable refresh intervals * - Cache priority based on user preferences * - Advanced ML model for fight predictions * - Historical trend analysis * - Export/import cache functionality * */ (() => { 'use strict'; // Global reload guard to prevent multiple simultaneous reload attempts let _reloadInProgress = false; // Global API budget interval tracker to prevent multiple timers let _apiBudgetIntervalId = null; // ============================================================================ // CONSTANTS // ============================================================================ const VERSION = '2.1.12-FINAL'; const GREEN_ARROW_UP = "data:image/svg+xml;utf8,"; const YELLOW_ARROW_UP = "data:image/svg+xml;utf8,"; const RED_ARROW_UP = "data:image/svg+xml;utf8,"; const PLANE_ICON_URL = 'https://raw.githubusercontent.com/infodump01/LE-Scouter/main/airport-xxl.png'; const HOSPITAL_ICON_URL = 'https://github.com/infodump01/LE-Scouter/raw/main/hospital.png'; const CIRCLE_ICON_URL = 'https://github.com/infodump01/LE-Scouter/raw/main/circle.png'; const PILL_ICON_URL = 'https://raw.githubusercontent.com/infodump01/LE-Scouter/main/pill-icon-2048x2048.png'; const API_MAX_ACTIVE = 3; const API_MIN_DELAY = 200; const API_CACHE_MAX_SIZE = 500; const API_CACHE_TTL_BASIC = 10000; const API_CACHE_TTL_DEFAULT = 30000; // RSI Cache Constants (NEW v1.8.0) const RSI_CACHE_DB_NAME = 'ATKScouterRSICache'; const RSI_CACHE_DB_VERSION = 1; const RSI_CACHE_STORE_NAME = 'rsiData'; // Cache expiry tiers const CACHE_TIERS = { FRESH: 2 * 24 * 60 * 60 * 1000, MODERATE: 5 * 24 * 60 * 60 * 1000, STALE: 7 * 24 * 60 * 60 * 1000 }; // Refresh priority levels const REFRESH_PRIORITY = { FIGHTING_NOW: 1, FACTION_MEMBER: 2, RECENT_TARGET: 3, FREQUENT_TARGET: 4, RANDOM_BOUNTY: 5 }; // Fight logging constants const DB_NAME = 'ATKScouterFightLog'; const DB_VERSION = 3; // v2.1.6: Incremented to fix missing index bug causing mobile crashes const PENDING_ATTACK_KEY = 'ff_pending_attack'; const MAX_FIGHTS_STORED = 2000; // Gym tier multipliers based on xanax consumption → energy → multiplier const GYM_TIERS = [ { energy: 0, mul: 1.0000 }, { energy: 200, mul: 1.2375 }, { energy: 500, mul: 1.4500 }, { energy: 1000, mul: 1.6000 }, { energy: 2000, mul: 1.7000 }, { energy: 2750, mul: 1.8000 }, { energy: 3000, mul: 1.8500 }, { energy: 3500, mul: 1.8500 }, { energy: 4000, mul: 2.0000 }, { energy: 6000, mul: 2.1750 }, { energy: 7000, mul: 2.2750 }, { energy: 8000, mul: 2.4250 }, { energy: 11000, mul: 2.5250 }, { energy: 12420, mul: 2.5500 }, { energy: 18000, mul: 2.7375 }, { energy: 18100, mul: 2.7850 }, { energy: 24140, mul: 3.0000 }, { energy: 31260, mul: 3.1000 }, { energy: 36610, mul: 3.1625 }, { energy: 46640, mul: 3.2625 }, { energy: 56520, mul: 3.3250 }, { energy: 67775, mul: 3.2875 }, { energy: 84535, mul: 3.3500 }, { energy: 106305, mul: 3.4500 }, { energy: 100000000, mul: 3.6500 } ]; // Location abbreviation map const LOC_TO_CODE = { "China": "CN", "Japan": "JP", "Mexico": "MX", "Canada": "CA", "United Kingdom": "GB", "UK": "GB", "Argentina": "AR", "UAE": "AE", "United Arab Emirates": "AE", "Dubai": "AE", "Switzerland": "CH", "South Africa": "ZA", "Hawaii": "HW", "Hawai'i": "HW" }; // ============================================================================ // ENVIRONMENT ADAPTER // ============================================================================ const Env = (() => { if (typeof PDA_httpGet === 'function') { return { httpRequest: d => d.method.toLowerCase() === 'get' ? PDA_httpGet(d.url).then(d.onload).catch(d.onerror) : PDA_httpPost(d.url, d.headers, d.data).then(d.onload).catch(d.onerror), setValue: (k, v) => localStorage.setItem(k, JSON.stringify(v)), getValue: (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, deleteValue: k => localStorage.removeItem(k), isPDA: true }; } return { httpRequest: GM_xmlhttpRequest, setValue: GM_setValue, getValue: GM_getValue, deleteValue: GM_deleteValue, isPDA: false }; })(); // ============================================================================ // SETTINGS // ============================================================================ const DEFAULTS = { lowHigh: 100, highMed: 120, lifeWeight: 0.1, drugWeight: 0.1, // RSI Cache settings rsiCacheEnabled: true, rsiCacheShowConfidence: true, rsiCacheBackgroundRefresh: true, // Bounty Sniper defaults sniperEnabled: false, sniperMinReward: 100000, sniperMaxLevel: 100, sniperRsiMin: 80, sniperRsiMax: 200, sniperShowOkay: true, sniperShowHospital: true, sniperHospitalMins: 1, sniperShowTraveling: false, sniperApiBudget: 10, sniperPagesToScan: 10, sniperSoundAlert: false, sniperSortBy: 'value', // 'value', 'reward', 'rsi', 'beaten' sniperFetchInterval: 45, // Jail Bust Sniper defaults bustSniperEnabled: false, bustSniperMinLevel: 1, bustSniperMaxLevel: 100, bustSniperMinSentence: 0, bustSniperMaxSentence: 6000, bustSniperPagesToScan: 2, bustSniperSoundAlert: false, bustSniperSortBy: 'priority', bustSniperFetchInterval: 30, bustSniperShowNormalJail: true, bustSniperShowFederalJail: true, bustSniperMaxTargets: 10 }; let API_KEY = Env.getValue('api_key', ''); const settings = { lowHigh: +Env.getValue('threshold_lowHigh', DEFAULTS.lowHigh), highMed: +Env.getValue('threshold_highMed', DEFAULTS.highMed), lifeWeight: +Env.getValue('lifeWeight', DEFAULTS.lifeWeight), drugWeight: +Env.getValue('drugWeight', DEFAULTS.drugWeight), // RSI Cache settings rsiCacheEnabled: Env.getValue('rsiCacheEnabled', DEFAULTS.rsiCacheEnabled) !== false, rsiCacheShowConfidence: Env.getValue('rsiCacheShowConfidence', DEFAULTS.rsiCacheShowConfidence) !== false, rsiCacheBackgroundRefresh: Env.getValue('rsiCacheBackgroundRefresh', DEFAULTS.rsiCacheBackgroundRefresh) !== false, // Bounty Sniper settings sniperEnabled: Env.getValue('sniperEnabled', DEFAULTS.sniperEnabled) === true || Env.getValue('sniperEnabled', DEFAULTS.sniperEnabled) === 'true', sniperMinReward: +Env.getValue('sniperMinReward', DEFAULTS.sniperMinReward), sniperMaxLevel: +Env.getValue('sniperMaxLevel', DEFAULTS.sniperMaxLevel), sniperRsiMin: +Env.getValue('sniperRsiMin', DEFAULTS.sniperRsiMin), sniperRsiMax: +Env.getValue('sniperRsiMax', DEFAULTS.sniperRsiMax), sniperShowOkay: Env.getValue('sniperShowOkay', DEFAULTS.sniperShowOkay) === true || Env.getValue('sniperShowOkay', DEFAULTS.sniperShowOkay) === 'true', sniperShowHospital: Env.getValue('sniperShowHospital', DEFAULTS.sniperShowHospital) === true || Env.getValue('sniperShowHospital', DEFAULTS.sniperShowHospital) === 'true', sniperHospitalMins: +Env.getValue('sniperHospitalMins', DEFAULTS.sniperHospitalMins), sniperShowTraveling: Env.getValue('sniperShowTraveling', DEFAULTS.sniperShowTraveling) === true || Env.getValue('sniperShowTraveling', DEFAULTS.sniperShowTraveling) === 'true', sniperApiBudget: +Env.getValue('sniperApiBudget', DEFAULTS.sniperApiBudget), sniperPagesToScan: +Env.getValue('sniperPagesToScan', DEFAULTS.sniperPagesToScan), sniperSoundAlert: Env.getValue('sniperSoundAlert', DEFAULTS.sniperSoundAlert) === true || Env.getValue('sniperSoundAlert', DEFAULTS.sniperSoundAlert) === 'true', sniperSortBy: Env.getValue('sniperSortBy', DEFAULTS.sniperSortBy), sniperFetchInterval: +Env.getValue('sniperFetchInterval', DEFAULTS.sniperFetchInterval), // Jail Bust Sniper settings bustSniperEnabled: Env.getValue('bustSniperEnabled', DEFAULTS.bustSniperEnabled) === true || Env.getValue('bustSniperEnabled', DEFAULTS.bustSniperEnabled) === 'true', bustSniperMinLevel: +Env.getValue('bustSniperMinLevel', DEFAULTS.bustSniperMinLevel), bustSniperMaxLevel: +Env.getValue('bustSniperMaxLevel', DEFAULTS.bustSniperMaxLevel), bustSniperMinSentence: +Env.getValue('bustSniperMinSentence', DEFAULTS.bustSniperMinSentence), bustSniperMaxSentence: +Env.getValue('bustSniperMaxSentence', DEFAULTS.bustSniperMaxSentence), bustSniperPagesToScan: +Env.getValue('bustSniperPagesToScan', DEFAULTS.bustSniperPagesToScan), bustSniperSoundAlert: Env.getValue('bustSniperSoundAlert', DEFAULTS.bustSniperSoundAlert) === true || Env.getValue('bustSniperSoundAlert', DEFAULTS.bustSniperSoundAlert) === 'true', bustSniperSortBy: Env.getValue('bustSniperSortBy', DEFAULTS.bustSniperSortBy), bustSniperFetchInterval: +Env.getValue('bustSniperFetchInterval', DEFAULTS.bustSniperFetchInterval), bustSniperShowNormalJail: Env.getValue('bustSniperShowNormalJail', DEFAULTS.bustSniperShowNormalJail) === true || Env.getValue('bustSniperShowNormalJail', DEFAULTS.bustSniperShowNormalJail) === 'true', bustSniperShowFederalJail: Env.getValue('bustSniperShowFederalJail', DEFAULTS.bustSniperShowFederalJail) === true || Env.getValue('bustSniperShowFederalJail', DEFAULTS.bustSniperShowFederalJail) === 'true', bustSniperMaxTargets: Math.min(10, Math.max(1, +Env.getValue('bustSniperMaxTargets', DEFAULTS.bustSniperMaxTargets))) }; // ============================================================================ // STYLES (consolidated) // ============================================================================ GM_addStyle(` /* Score badges */ .ff-score-badge { display: inline-block; margin-left: 8px; padding: 4px 8px; border-radius: 6px; font-size: 0.95em; color: #fff; } .ff-score-badge.high { background: #c62828; } .ff-score-badge.med { background: #f9a825; } .ff-score-badge.low { background: #2e7d32; } .ff-score-badge.wounded { box-shadow: 0 0 6px 2px rgba(229, 57, 53, 0.8); } /* Cache confidence indicators */ .ff-cache-indicator { display: inline-block; margin-left: 4px; font-size: 0.85em; vertical-align: middle; opacity: 0.7; } .ff-cache-indicator.high { color: #4caf50; } .ff-cache-indicator.medium { color: #ff9800; } .ff-cache-indicator.low { color: #f44336; } /* API Budget indicator - Translucent overlay on top of settings cog */ .ff-api-budget { position: fixed; bottom: 48px; /* Overlays top ~40% of cog (20px + 50px - 22px) */ left: 20px; /* Aligned with cog */ width: 50px; /* Matches cog width */ background: linear-gradient(180deg, rgba(76, 175, 80, 0.75) 0%, /* More transparent: 75% instead of 92% */ rgba(76, 175, 80, 0.45) 100%); /* More transparent: 45% instead of 65% */ color: #fff; padding: 3px 0; border-radius: 25px 25px 0 0; /* Rounded top matching cog curve */ font-size: 9px; font-weight: bold; z-index: 10001; /* ABOVE cog (10000) so text is visible */ font-family: monospace; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.15); pointer-events: none; /* CRITICAL: Allows clicks to pass through to cog */ text-align: center; backdrop-filter: blur(3px); /* Glass-morphism effect */ -webkit-backdrop-filter: blur(3px); /* Safari support */ height: 22px; line-height: 1.2; display: flex; align-items: center; justify-content: center; } .ff-api-budget.warning { background: linear-gradient(180deg, rgba(255, 152, 0, 0.75) 0%, /* More transparent */ rgba(255, 152, 0, 0.45) 100%); } .ff-api-budget.danger { background: linear-gradient(180deg, rgba(244, 67, 54, 0.75) 0%, /* More transparent */ rgba(244, 67, 54, 0.45) 100%); } /* Cache stats panel */ .ff-cache-stats { margin: 15px 0; padding: 12px; background: rgba(76, 175, 80, 0.1); border: 1px solid rgba(76, 175, 80, 0.3); border-radius: 6px; } .ff-cache-stats-title { font-weight: 600; color: #4caf50; margin-bottom: 8px; font-size: 0.95em; } .ff-cache-stat-row { display: flex; justify-content: space-between; margin: 4px 0; font-size: 0.85em; } .ff-cache-stat-label { color: #999; } .ff-cache-stat-value { color: #fff; font-weight: 500; } /* Honor wrap positioning */ div[class*="honorWrap"] { overflow: visible !important; position: relative !important; } .honor-text-wrap { position: relative !important; } /* Bounty page uses same positioning as faction pages */ li[data-ff-bounty="1"] > div { position: relative !important; overflow: visible !important; } /* List arrow indicators */ .ff-list-arrow-img { position: absolute; bottom: 0; left: 50%; width: 16px !important; height: 16px !important; max-width: 16px !important; max-height: 16px !important; pointer-events: auto; z-index: 10000; transform: translate(-50%, 38%); display: block; transition: box-shadow 0.15s; } .ff-list-arrow-img.wounded { box-shadow: 0 0 10px 4px #c62828, 0 0 2px 2px rgba(255, 255, 255, 0.13); border-radius: 5px; } /* Tooltip */ .ff-tooltip-viewport { position: absolute; background: rgba(0, 0, 0, 0.85); color: #fff; padding: 5px 8px; border-radius: 4px; font-size: 0.85em; line-height: 1.2em; white-space: nowrap; z-index: 2147483647 !important; pointer-events: none; } .ff-tooltip-viewport a { color: #ff5252 !important; text-decoration: underline; } .ff-tooltip-viewport a:visited { color: #e53935 !important; } /* FAB and Modal */ .ff-fab { position: fixed; bottom: 20px; left: 20px; width: 50px; height: 50px; border-radius: 25px; background: rgba(34, 34, 34, 0.9); /* Slightly transparent to allow overlay text visibility */ color: #eee; font-size: 26px; line-height: 50px; text-align: center; cursor: pointer; z-index: 10000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); transition: background 0.2s; } .ff-fab:hover { background: rgba(51, 51, 51, 0.9); } /* Bounty Sniper Panel - Mobile Optimized & Draggable */ .ff-sniper-panel { position: fixed; bottom: 20px; left: 80px; width: 240px; background: linear-gradient(135deg, rgba(25,25,35,0.98) 0%, rgba(15,15,25,0.98) 100%); border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; z-index: 10000; box-shadow: 0 4px 20px rgba(0,0,0,0.6); font-size: 12px; color: #e0e0e0; overflow: hidden; touch-action: none; } .ff-sniper-panel.dragging { box-shadow: 0 8px 32px rgba(79,195,247,0.3); opacity: 0.95; } .ff-sniper-panel.collapsed .ff-sniper-list, .ff-sniper-panel.collapsed .ff-sniper-status { display: none; } .ff-sniper-panel.follower { border-color: rgba(255,193,7,0.4); } .ff-sniper-panel.follower .ff-sniper-leader-btn { display: inline-flex; } .ff-sniper-leader-btn { display: none; font-size: 12px; cursor: pointer; padding: 2px 4px; margin-left: 4px; border-radius: 4px; background: rgba(255,193,7,0.2); align-items: center; justify-content: center; transition: background 0.2s; } .ff-sniper-leader-btn:hover { background: rgba(255,193,7,0.4); } .ff-sniper-leader-btn:active { background: rgba(255,193,7,0.6); } .ff-sniper-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; background: rgba(0,0,0,0.3); cursor: grab; user-select: none; } .ff-sniper-header:active { cursor: grabbing; } .ff-sniper-header:hover { background: rgba(255,255,255,0.05); } .ff-sniper-title { font-weight: 600; color: #4fc3f7; display: flex; align-items: center; gap: 6px; font-size: 12px; } .ff-sniper-badge { background: #4caf50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 8px; font-weight: 700; min-width: 16px; text-align: center; } .ff-sniper-badge.empty { background: #666; } .ff-sniper-toggle { font-size: 12px; color: #888; padding: 6px 8px; margin: -6px -8px; cursor: pointer; min-width: 32px; min-height: 32px; display: flex; align-items: center; justify-content: center; } .ff-sniper-toggle:hover { color: #fff; } .ff-sniper-list { max-height: 160px; overflow-y: auto; overflow-x: hidden; } .ff-sniper-item { display: flex; align-items: center; padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); gap: 6px; } .ff-sniper-item:last-child { border-bottom: none; } .ff-sniper-item:hover { background: rgba(255,255,255,0.05); } .ff-sniper-rsi { font-size: 10px; padding: 2px 4px; border-radius: 3px; font-weight: 600; min-width: 32px; text-align: center; flex-shrink: 0; } .ff-sniper-rsi.low { background: rgba(76,175,80,0.3); color: #81c784; } .ff-sniper-rsi.med { background: rgba(255,193,7,0.3); color: #ffd54f; } .ff-sniper-rsi.high { background: rgba(198,40,40,0.3); color: #ef5350; } .ff-sniper-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px; } .ff-sniper-name a { color: #e0e0e0; text-decoration: none; } .ff-sniper-name a:hover { color: #4fc3f7; } .ff-sniper-reward { font-size: 10px; color: #81c784; font-weight: 600; flex-shrink: 0; } .ff-sniper-beaten { font-size: 9px; color: #ffd700; flex-shrink: 0; } .ff-sniper-atk { background: #c62828; color: #fff; font-size: 9px; padding: 3px 6px; border-radius: 3px; text-decoration: none; font-weight: 600; cursor: pointer; flex-shrink: 0; } .ff-sniper-atk:hover { background: #e53935; } .ff-sniper-status { padding: 5px 10px; font-size: 9px; color: #888; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ff-sniper-status .leader { color: #81c784; } .ff-sniper-status .follower { color: #ffd54f; } /* Mobile-specific adjustments */ @media (max-width: 768px) { .ff-sniper-panel { width: 220px; bottom: 70px; left: 10px; } .ff-sniper-item { padding: 8px 10px; } .ff-sniper-atk { padding: 5px 8px; font-size: 10px; } .ff-sniper-toggle { min-width: 40px; min-height: 40px; } } /* ================================================================== JAIL BUST SNIPER PANEL STYLES ================================================================== */ .ff-bust-panel { position: fixed; top: 300px; left: 20px; width: 320px; background: linear-gradient(135deg, rgba(40, 20, 50, 0.96) 0%, rgba(20, 10, 30, 0.96) 100%); border: 2px solid rgba(156, 39, 176, 0.6); border-radius: 10px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(156, 39, 176, 0.3); z-index: 2147483646; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 13px; color: #e6e6e6; backdrop-filter: blur(4px); transition: transform 0.2s; } .ff-bust-panel.collapsed { width: 52px; } .ff-bust-panel.follower { opacity: 0.88; } .ff-bust-panel:hover { transform: scale(1.01); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.7); } .ff-bust-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: rgba(156, 39, 176, 0.2); border-bottom: 1px solid rgba(156, 39, 176, 0.4); cursor: move; user-select: none; border-radius: 8px 8px 0 0; } .ff-bust-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; flex: 1; } .ff-bust-badge { background: linear-gradient(135deg, #9c27b0 0%, #7b1fa2 100%); color: #fff; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; min-width: 20px; text-align: center; box-shadow: 0 2px 6px rgba(156, 39, 176, 0.5); } .ff-bust-badge.empty { background: #555; box-shadow: none; } .ff-bust-toggle { cursor: pointer; font-size: 16px; padding: 4px 8px; user-select: none; transition: transform 0.2s; } .ff-bust-toggle:hover { transform: scale(1.2); } .ff-bust-list { max-height: 400px; overflow-y: auto; padding: 4px; } .ff-bust-panel.collapsed .ff-bust-list { display: none; } .ff-bust-item { display: grid; grid-template-columns: 60px 1fr 80px; gap: 8px; align-items: center; padding: 8px 10px; margin: 4px 0; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(156, 39, 176, 0.2); border-radius: 6px; transition: all 0.2s; } .ff-bust-item:hover { background: rgba(156, 39, 176, 0.15); border-color: rgba(156, 39, 176, 0.4); transform: translateX(2px); } .ff-bust-time { font-weight: 700; font-size: 12px; color: #ba68c8; white-space: nowrap; } .ff-bust-time.urgent { color: #ff5252; animation: pulse-bust 1.5s ease-in-out infinite; } .ff-bust-time.soon { color: #ffb74d; } @keyframes pulse-bust { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } .ff-bust-name { font-weight: 500; display: flex; align-items: center; gap: 4px; min-width: 0; /* Important for flex truncation */ } .ff-bust-name a { color: #e6e6e6 !important; text-decoration: none !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; /* Important for flex child truncation */ } .ff-bust-name a:hover { color: #ba68c8 !important; } .ff-bust-level { display: inline-block; padding: 1px 5px; border-radius: 4px; font-size: 10px; font-weight: 700; flex-shrink: 0; /* Prevent level badge from shrinking */ } .ff-bust-level.easy { background: #4caf50; color: #fff; } .ff-bust-level.medium { background: #ff9800; color: #fff; } .ff-bust-level.hard { background: #f44336; color: #fff; } .ff-bust-btn { background: linear-gradient(135deg, #9c27b0 0%, #7b1fa2 100%); color: #fff !important; text-align: center; padding: 6px 12px; border-radius: 6px; text-decoration: none !important; font-weight: 600; font-size: 11px; transition: all 0.2s; box-shadow: 0 2px 6px rgba(156, 39, 176, 0.4); display: block; border: none; cursor: pointer; font-family: inherit; } .ff-bust-btn:hover { background: linear-gradient(135deg, #ab47bc 0%, #8e24aa 100%); box-shadow: 0 4px 12px rgba(156, 39, 176, 0.6); transform: translateY(-1px); } .ff-bust-status { padding: 8px 12px; background: rgba(0, 0, 0, 0.3); border-top: 1px solid rgba(156, 39, 176, 0.2); font-size: 11px; color: #aaa; text-align: center; border-radius: 0 0 8px 8px; } .ff-bust-panel.collapsed .ff-bust-status { display: none; } .ff-bust-status .scanning { color: #ba68c8; font-weight: 600; } .ff-bust-status .leader { color: #4caf50; } .ff-bust-status .follower { color: #ff9800; } .ff-bust-jail-type { display: inline-block; margin-left: 4px; opacity: 0.8; font-size: 10px; } /* Mobile/PDA adjustments */ @media (max-width: 768px) { .ff-bust-panel { width: 280px; left: 10px; } .ff-bust-item { padding: 6px 8px; } .ff-bust-btn { padding: 4px 8px; font-size: 10px; } } .ff-modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); z-index: 2147483645 !important; display: none; } .ff-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #2b2b2b; color: #eee; padding: 20px; border-radius: 8px; z-index: 2147483648 !important; min-width: 360px; max-width: 90vw; max-height: 85vh; overflow-y: auto; display: none; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); font-family: sans-serif; } .ff-modal h3 { margin: 0 0 12px; font-size: 1.3em; display: flex; align-items: center; } .ff-modal h3::before { content: "⚙️"; margin-right: 8px; } .ff-tabs { display: flex; margin-bottom: 12px; flex-wrap: wrap; } .ff-tab { flex: 1; padding: 8px; text-align: center; cursor: pointer; color: #aaa; border-bottom: 2px solid transparent; user-select: none; transition: color 0.2s, border-color 0.2s; min-width: 80px; } .ff-tab.active { color: #fff; border-color: #fff; } .ff-tab-content { display: none; } .ff-tab-content.active { display: block; } .ff-modal label { display: block; margin: 12px 0 4px; font-size: 0.9em; color: #ccc; } .ff-modal input { width: 100%; padding: 6px; border: 1px solid #444; border-radius: 4px; background: #333; color: #eee; font-size: 1em; box-sizing: border-box; } .ff-modal .btn { display: inline-block; margin-top: 16px; padding: 8px 14px; border: none; border-radius: 4px; font-size: 0.95em; cursor: pointer; transition: background 0.2s; } .ff-modal .btn-save { background: #4caf50; color: #fff; margin-right: 8px; } .ff-modal .btn-save:hover { background: #66bb6a; } .ff-modal .btn-cancel { background: #c62828; color: #fff; } .ff-modal .btn-cancel:hover { background: #e53935; } .ff-modal .btn-clear { background: #555; color: #fff; margin-left: 8px; } .ff-modal .btn-clear:hover { background: #666; } /* Status icons (travel/hospital) - consolidated positioning */ .ff-travel-icon, .ff-hospital-icon { position: absolute; right: 4px; top: -2px; height: 12px !important; width: 12px !important; max-width: 12px !important; max-height: 12px !important; z-index: 2147483647 !important; opacity: 0.92; pointer-events: auto; transition: filter 0.15s, box-shadow 0.15s; } .ff-travel-icon { filter: grayscale(1) brightness(0.7); } .ff-travel-icon.ff-traveling { filter: grayscale(0) brightness(1) drop-shadow(0 0 3px #2094fa) drop-shadow(0 0 2px #42a5f5); } .ff-travel-icon.ff-abroad { filter: grayscale(0) brightness(1) drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 2px #ffb300); } .ff-travel-icon:hover, .ff-hospital-icon:hover { filter: brightness(1.4) !important; } /* Countdown chips and location badges */ .ff-countdown-chip, .ff-loc-badge { position: absolute; right: 4px; bottom: -4px; padding: 0 3px; font-size: 9px; line-height: 10px; background: rgba(0, 0, 0, 0.65); color: #fff; border-radius: 3px; font-weight: 700; z-index: 2147483647 !important; pointer-events: none; letter-spacing: 0.2px; text-transform: uppercase; white-space: nowrap; } .ff-loc-badge[data-kind="travel"] { outline: 1px solid rgba(33, 150, 243, 0.9); } .ff-loc-badge[data-kind="return"] { outline: 1px solid rgba(3, 169, 244, 0.9); } .ff-loc-badge[data-kind="abroad"] { background: #ffb300 !important; color: #111 !important; outline: 1px solid #ffd54f !important; box-shadow: 0 0 6px rgba(255, 179, 0, 0.45); } /* BOUNTY PAGE SPECIFIC - tighter spacing for smaller cells */ li[data-ff-bounty="1"] .ff-travel-icon, li[data-ff-bounty="1"] .ff-hospital-icon { top: 2px; } li[data-ff-bounty="1"] .ff-countdown-chip, li[data-ff-bounty="1"] .ff-loc-badge { bottom: 2px; } /* API warning banner */ #ff-api-warning { position: fixed; left: 20px; bottom: 80px; max-width: 260px; background: rgba(33, 33, 33, 0.95); color: #ffeb3b; padding: 6px 10px; border-radius: 6px; font-size: 11px; line-height: 1.3; z-index: 2147483647; box-shadow: 0 0 10px rgba(0, 0, 0, 0.6); cursor: pointer; } /* RSI+ Experimental Panel */ #ff-exp-panel, #ff-exp-panel * { color: #e6e6e6 !important; } #ff-exp-panel .ff-chip-label { display: inline-block; padding: 2px 6px; border-radius: 4px; background: rgba(66, 165, 245, 0.18); border: 1px solid rgba(66, 165, 245, 0.45); } #ff-exp-panel .ff-mini-btn { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.16); color: #e6e6e6; cursor: pointer; font-size: 11px; } #ff-exp-panel .ff-mini-btn:hover { background: rgba(255, 255, 255, 0.14); } #ff-exp-panel .ff-note { opacity: 0.8; font-size: 11px; margin-left: 6px; } /* Fight Intelligence Panel */ #ff-intel-panel { margin-top: 8px; background: linear-gradient(135deg, rgba(30,30,40,0.95) 0%, rgba(20,20,30,0.95) 100%); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 12px 14px; color: #e6e6e6; font-size: 12px; } #ff-intel-panel .ff-intel-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); } #ff-intel-panel .ff-intel-title { font-weight: 600; font-size: 13px; color: #4fc3f7; } #ff-intel-panel .ff-intel-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.05); } #ff-intel-panel .ff-intel-row:last-child { border-bottom: none; } #ff-intel-panel .ff-intel-label { color: #aaa; font-size: 11px; } #ff-intel-panel .ff-intel-value { font-weight: 600; font-size: 13px; } #ff-intel-panel .ff-intel-value.positive { color: #4caf50; } #ff-intel-panel .ff-intel-value.warning { color: #f9a825; } #ff-intel-panel .ff-intel-value.danger { color: #ef5350; } #ff-intel-panel .ff-intel-value.neutral { color: #90a4ae; } #ff-intel-panel .ff-win-chance-bar { width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden; margin-top: 4px; } #ff-intel-panel .ff-win-chance-fill { height: 100%; border-radius: 4px; transition: width 0.3s ease; } #ff-intel-panel .ff-history-tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 4px; } #ff-intel-panel .ff-history-tag.win { background: rgba(76, 175, 80, 0.2); color: #4caf50; border: 1px solid rgba(76, 175, 80, 0.3); } #ff-intel-panel .ff-history-tag.loss { background: rgba(239, 83, 80, 0.2); color: #ef5350; border: 1px solid rgba(239, 83, 80, 0.3); } #ff-intel-panel .ff-no-data { color: #666; font-style: italic; text-align: center; padding: 8px; } #ff-intel-panel .ff-confidence { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; margin-left: 6px; background: rgba(255,255,255,0.1); color: #888; } #ff-intel-panel .ff-confidence.high { background: rgba(76, 175, 80, 0.15); color: #81c784; } #ff-intel-panel .ff-confidence.medium { background: rgba(255, 193, 7, 0.15); color: #ffd54f; } #ff-intel-panel .ff-confidence.low { background: rgba(158, 158, 158, 0.15); color: #9e9e9e; } /* Anti-overlap utility */ .ff-anti-overlap { z-index: 2147483647 !important; will-change: transform; pointer-events: none; } /* Stats tab styles */ .ff-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; margin: 12px 0; } .ff-stat-card { background: rgba(255,255,255,0.05); border-radius: 8px; padding: 12px; text-align: center; } .ff-stat-value { font-size: 1.8em; font-weight: 700; color: #4caf50; display: block; } .ff-stat-value.warning { color: #f9a825; } .ff-stat-value.danger { color: #c62828; } .ff-stat-label { font-size: 0.75em; color: #aaa; text-transform: uppercase; letter-spacing: 0.5px; } .ff-calibration-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 0.85em; color: #e0e0e0; } .ff-calibration-table th, .ff-calibration-table td { padding: 6px 8px; text-align: center; border-bottom: 1px solid rgba(255,255,255,0.1); color: #e0e0e0; } .ff-calibration-table th { color: #aaa; font-weight: 600; } .ff-calibration-table td { color: #ccc; } .ff-recent-fights { max-height: 200px; overflow-y: auto; margin: 12px 0; } .ff-fight-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.85em; color: #ccc; } .ff-fight-row:hover { background: rgba(255,255,255,0.03); } .ff-fight-row span { color: #ccc; } .ff-fight-win { color: #4caf50; } .ff-fight-loss { color: #c62828; } .ff-fight-other { color: #9e9e9e; } .ff-btn-small { padding: 4px 8px; font-size: 0.8em; border-radius: 4px; border: none; cursor: pointer; margin: 2px; } .ff-btn-export { background: #2196f3; color: #fff; } .ff-btn-export:hover { background: #42a5f5; } .ff-btn-danger { background: #c62828; color: #fff; } .ff-btn-danger:hover { background: #e53935; } .ff-empty-state { text-align: center; padding: 30px; color: #666; } .ff-log-indicator { position: fixed; bottom: 80px; left: 80px; background: rgba(76, 175, 80, 0.9); color: #fff; padding: 8px 12px; border-radius: 6px; font-size: 12px; z-index: 10001; animation: ff-fade-out 3s forwards; pointer-events: none; } @keyframes ff-fade-out { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } `); // ============================================================================ // UTILITIES // ============================================================================ /** * Escapes HTML to prevent XSS */ function escapeHtml(str) { if (typeof str !== 'string') return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** * Gets gym multiplier based on xanax count */ function getGymMultiplier(xanCount) { const energy = xanCount * 250; // Last real gym tier values const LAST_TIER_ENERGY = 106305; const LAST_TIER_MUL = 3.45; // If beyond last gym tier, use logarithmic scaling if (energy > LAST_TIER_ENERGY) { const extraEnergy = energy - LAST_TIER_ENERGY; // Logarithmic growth: every ~10x increase in extra energy adds ~1.0 to multiplier // This means someone with 10x more training beyond max gym gets ~1.0 higher multiplier const extraBonus = Math.log10(extraEnergy / 10000 + 1) * 1.0; const result = LAST_TIER_MUL + extraBonus; return result; } // Within gym tiers, use the tier lookup let multiplier = 1; for (const tier of GYM_TIERS) { if (energy >= tier.energy && tier.energy <= LAST_TIER_ENERGY) { multiplier = tier.mul; } } return multiplier; } /** * Abbreviates location name to country code */ function abbreviateLocation(loc) { if (!loc) return ''; if (LOC_TO_CODE[loc]) return LOC_TO_CODE[loc]; const upper = String(loc).toUpperCase(); const firstWord = upper.split(/\s+/)[0]; return (firstWord.slice(0, 3) || upper.slice(0, 3)).replace(/[^A-Z]/g, ''); } /** * Parses travel info from status description */ function parseTravelInfo(desc) { if (!desc) return null; let match; if ((match = desc.match(/^Traveling to\s+(.+)$/i))) { return { kind: 'travel', loc: match[1].trim() }; } if ((match = desc.match(/^Returning to Torn from\s+(.+)$/i))) { return { kind: 'return', loc: match[1].trim() }; } if ((match = desc.match(/^In\s+(.+)$/i))) { return { kind: 'abroad', loc: match[1].trim() }; } return null; } /** * Parses minutes from status text */ function parseMinutesFromText(txt) { if (!txt) return null; const match = String(txt).match(/(\d+)\s*min/i); return match ? parseInt(match[1], 10) : null; } /** * Shows a temporary notification */ function showNotification(message, isError = false) { const existing = document.querySelector('.ff-log-indicator'); if (existing) existing.remove(); const notif = document.createElement('div'); notif.className = 'ff-log-indicator'; if (isError) notif.style.background = 'rgba(198, 40, 40, 0.9)'; notif.textContent = message; document.body.appendChild(notif); setTimeout(() => notif.remove(), 3000); } // ============================================================================ // INDEXEDDB FIGHT LOG // ============================================================================ const FightDB = (() => { let db = null; let dbReady = false; let dbError = false; /** * Opens/creates the IndexedDB database */ function openDatabase() { return new Promise((resolve, reject) => { if (db && dbReady) { resolve(db); return; } if (dbError) { reject(new Error('Database previously failed to open')); return; } // Check IndexedDB availability if (!window.indexedDB) { dbError = true; reject(new Error('IndexedDB not supported')); return; } try { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = (e) => { console.error('FightDB: Error opening database', e); dbError = true; reject(e.target.error); }; request.onsuccess = (e) => { db = e.target.result; dbReady = true; // Handle connection errors db.onerror = (event) => { console.error('FightDB: Database error', event.target.error); }; resolve(db); }; request.onupgradeneeded = (e) => { const database = e.target.result; const transaction = e.target.transaction; console.log(`FightDB: Upgrading database from v${e.oldVersion} to v${e.newVersion}`); // Delete and recreate fights store to fix any schema issues if (database.objectStoreNames.contains('fights')) { console.log('FightDB: Deleting old fights store to fix schema'); database.deleteObjectStore('fights'); } // Create fights store with proper indexes const fightStore = database.createObjectStore('fights', { keyPath: 'id', autoIncrement: true }); fightStore.createIndex('timestamp', 'timestamp', { unique: false }); fightStore.createIndex('opponentId', 'opponentId', { unique: false }); fightStore.createIndex('result', 'outcome', { unique: false }); fightStore.createIndex('rsiRaw', 'rsiRaw', { unique: false }); console.log('FightDB: Created fights store with indexes'); // Delete and recreate players store to fix any schema issues if (database.objectStoreNames.contains('players')) { console.log('FightDB: Deleting old players store to fix schema'); database.deleteObjectStore('players'); } // Create players store with proper indexes const playerStore = database.createObjectStore('players', { keyPath: 'opponentId' }); playerStore.createIndex('lastFought', 'lastFought', { unique: false }); playerStore.createIndex('fightCount', 'fightCount', { unique: false }); console.log('FightDB: Created players store with indexes'); }; } catch (e) { dbError = true; reject(e); } }); } /** * Logs a fight to the database */ async function logFight(fightData) { try { const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction(['fights', 'players'], 'readwrite'); const fightStore = transaction.objectStore('fights'); const playerStore = transaction.objectStore('players'); // Add fight record const fightRecord = { ...fightData, timestamp: Date.now() }; const addRequest = fightStore.add(fightRecord); addRequest.onsuccess = () => { // Update player record const playerGet = playerStore.get(fightData.opponentId); playerGet.onsuccess = () => { const existing = playerGet.result || { opponentId: fightData.opponentId, // PATCHED: Fixed typo from 'odefinerId' opponentName: fightData.opponentName, fightCount: 0, wins: 0, losses: 0 }; existing.fightCount++; existing.lastFought = Date.now(); existing.opponentName = fightData.opponentName || existing.opponentName; if (fightData.outcome === 'win') existing.wins++; else if (fightData.outcome === 'loss') existing.losses++; playerStore.put(existing); }; resolve(addRequest.result); }; addRequest.onerror = () => reject(addRequest.error); transaction.oncomplete = () => { // Cleanup old records if over limit cleanupOldFights(); }; }); } catch (e) { console.error('FightDB: Error logging fight', e); throw e; } } /** * Removes oldest fights if over the limit */ async function cleanupOldFights() { try { const database = await openDatabase(); const transaction = database.transaction('fights', 'readwrite'); const store = transaction.objectStore('fights'); const countRequest = store.count(); countRequest.onsuccess = () => { const count = countRequest.result; if (count <= MAX_FIGHTS_STORED) return; const toDelete = count - MAX_FIGHTS_STORED; const index = store.index('timestamp'); const cursor = index.openCursor(); let deleted = 0; cursor.onsuccess = (e) => { const c = e.target.result; if (c && deleted < toDelete) { store.delete(c.primaryKey); deleted++; c.continue(); } }; }; } catch (e) { console.error('FightDB: Cleanup error', e); } } /** * Gets all fights */ async function getAllFights() { try { const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction('fights', 'readonly'); const store = transaction.objectStore('fights'); const request = store.getAll(); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } catch (e) { console.error('FightDB: Error getting fights', e); return []; } } /** * Gets recent fights (last N) */ async function getRecentFights(limit = 50) { try { const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction('fights', 'readonly'); const store = transaction.objectStore('fights'); const index = store.index('timestamp'); const request = index.openCursor(null, 'prev'); const results = []; request.onsuccess = (e) => { const cursor = e.target.result; if (cursor && results.length < limit) { results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; request.onerror = () => reject(request.error); }); } catch (e) { console.error('FightDB: Error getting recent fights', e); return []; } } /** * Gets fight history for a specific opponent */ async function getOpponentHistory(opponentId) { try { const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction('fights', 'readonly'); const store = transaction.objectStore('fights'); const index = store.index('opponentId'); // PATCHED: Fixed typo from 'odefinerId' const request = index.getAll(opponentId); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } catch (e) { console.error('FightDB: Error getting opponent history', e); return []; } } /** * Gets player record */ async function getPlayerRecord(opponentId) { try { const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction('players', 'readonly'); const store = transaction.objectStore('players'); const request = store.get(opponentId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } catch (e) { console.error('FightDB: Error getting player record', e); return null; } } /** * Gets statistics by RSI bucket */ async function getStatsByRsiBucket() { const fights = await getAllFights(); const buckets = { '0-50': { wins: 0, losses: 0, other: 0, total: 0 }, '50-75': { wins: 0, losses: 0, other: 0, total: 0 }, '75-100': { wins: 0, losses: 0, other: 0, total: 0 }, '100-125': { wins: 0, losses: 0, other: 0, total: 0 }, '125-150': { wins: 0, losses: 0, other: 0, total: 0 }, '150+': { wins: 0, losses: 0, other: 0, total: 0 } }; for (const fight of fights) { const rsi = fight.rsiAdjusted || fight.rsiRaw || 100; let bucket; if (rsi < 50) bucket = '0-50'; else if (rsi < 75) bucket = '50-75'; else if (rsi < 100) bucket = '75-100'; else if (rsi < 125) bucket = '100-125'; else if (rsi < 150) bucket = '125-150'; else bucket = '150+'; buckets[bucket].total++; if (fight.outcome === 'win') buckets[bucket].wins++; else if (fight.outcome === 'loss') buckets[bucket].losses++; else buckets[bucket].other++; } return buckets; } /** * Gets overall statistics */ async function getOverallStats() { const fights = await getAllFights(); const stats = { total: fights.length, wins: 0, losses: 0, other: 0, avgRsi: 0, avgRsiOnWin: 0, avgRsiOnLoss: 0 }; if (fights.length === 0) return stats; let rsiSum = 0, winRsiSum = 0, lossRsiSum = 0; for (const fight of fights) { const rsi = fight.rsiAdjusted || fight.rsiRaw || 100; rsiSum += rsi; if (fight.outcome === 'win') { stats.wins++; winRsiSum += rsi; } else if (fight.outcome === 'loss') { stats.losses++; lossRsiSum += rsi; } else { stats.other++; } } stats.avgRsi = rsiSum / fights.length; stats.avgRsiOnWin = stats.wins > 0 ? winRsiSum / stats.wins : 0; stats.avgRsiOnLoss = stats.losses > 0 ? lossRsiSum / stats.losses : 0; stats.winRate = stats.total > 0 ? (stats.wins / (stats.wins + stats.losses)) * 100 : 0; return stats; } /** * Exports all data as JSON */ async function exportData() { const fights = await getAllFights(); const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction('players', 'readonly'); const store = transaction.objectStore('players'); const request = store.getAll(); request.onsuccess = () => { resolve({ exportDate: new Date().toISOString(), version: VERSION, fights: fights, players: request.result || [] }); }; request.onerror = () => reject(request.error); }); } /** * Clears all data */ async function clearAllData() { try { const database = await openDatabase(); return new Promise((resolve, reject) => { const transaction = database.transaction(['fights', 'players'], 'readwrite'); transaction.objectStore('fights').clear(); transaction.objectStore('players').clear(); transaction.oncomplete = () => resolve(true); transaction.onerror = () => reject(transaction.error); }); } catch (e) { console.error('FightDB: Error clearing data', e); throw e; } } /** * Check if database is available */ async function isAvailable() { try { await openDatabase(); return true; } catch { return false; } } /** * Gets win rate for a specific RSI value (finds the appropriate bucket) * Returns: { winRate, totalFights, wins, losses, confidence } */ async function getWinRateForRSI(rsiValue) { const fights = await getAllFights(); // Find fights in a range around this RSI (±15%) const margin = 15; const lower = rsiValue - margin; const upper = rsiValue + margin; let wins = 0, losses = 0, total = 0; for (const fight of fights) { const rsi = fight.rsiAdjusted || fight.rsiRaw; if (rsi === null || rsi === undefined) continue; if (rsi >= lower && rsi < upper) { total++; if (fight.outcome === 'win') wins++; else if (fight.outcome === 'loss') losses++; } } // Determine confidence level let confidence = 'none'; if (total >= 20) confidence = 'high'; else if (total >= 10) confidence = 'medium'; else if (total >= 3) confidence = 'low'; return { winRate: total > 0 ? (wins / total) * 100 : null, totalFights: total, wins, losses, confidence, rsiRange: `${Math.round(lower)}-${Math.round(upper)}%` }; } /** * Auto-update learned RSI parameters when a fight is logged * Uses online logistic regression update */ function updateLearnedRSI(rsiValue, isWin) { try { // Get current parameters let theta0 = Number(Env.getValue('ff_lrsi_theta0', 0)); let theta1 = Number(Env.getValue('ff_lrsi_theta1', 0.12)); let hist = Env.getValue('ff_lrsi_hist', []) || []; // Add to history const xRaw = rsiValue - 100; // Center around 100% const y = isWin ? 1 : 0; hist.push({ x: xRaw, y, ts: Date.now() }); // Keep only last 500 entries if (hist.length > 500) { hist = hist.slice(-500); } // Perform gradient descent update (learning rate = 0.01) const lr = 0.01; const pWin = 1 / (1 + Math.exp(-(theta0 + theta1 * xRaw))); const error = y - pWin; theta0 += lr * error; theta1 += lr * error * xRaw; // Clamp theta1 to reasonable bounds theta1 = Math.max(0.01, Math.min(0.5, theta1)); // Save updated parameters Env.setValue('ff_lrsi_theta0', theta0); Env.setValue('ff_lrsi_theta1', theta1); Env.setValue('ff_lrsi_hist', hist); Env.setValue('ff_lrsi_meta', { n: hist.length, ts: Date.now() }); console.log(`Win Chance model updated: θ0=${theta0.toFixed(4)}, θ1=${theta1.toFixed(4)}, n=${hist.length}`); return { theta0, theta1, historyLength: hist.length }; } catch (e) { console.error('FightDB: Error updating learned RSI', e); return null; } } /** * Gets learned RSI win probability for a given RSI value */ function getLearnedWinProbability(rsiValue) { const theta0 = Number(Env.getValue('ff_lrsi_theta0', 0)); const theta1 = Number(Env.getValue('ff_lrsi_theta1', 0.12)); const hist = Env.getValue('ff_lrsi_hist', []) || []; const xRaw = rsiValue - 100; const pWin = 1 / (1 + Math.exp(-(theta0 + theta1 * xRaw))); return { probability: pWin * 100, theta0, theta1, dataPoints: hist.length, confidence: hist.length >= 50 ? 'high' : hist.length >= 20 ? 'medium' : hist.length >= 5 ? 'low' : 'none' }; } return { logFight, getAllFights, getRecentFights, getOpponentHistory, getPlayerRecord, getStatsByRsiBucket, getOverallStats, exportData, clearAllData, isAvailable, getWinRateForRSI, updateLearnedRSI, getLearnedWinProbability }; })(); // ============================================================================ // FIGHT CAPTURE SYSTEM // ============================================================================ const FightCapture = (() => { /** * Stores a pending attack prediction */ function storePendingAttack(data) { try { const pending = { opponentId: data.opponentId, opponentName: data.opponentName, rsiRaw: data.rsiRaw, rsiAdjusted: data.rsiAdjusted, riskClass: data.riskClass, oppLife: data.oppLife, oppLevel: data.oppLevel, oppSnapshot: data.oppSnapshot, timestamp: Date.now() }; sessionStorage.setItem(PENDING_ATTACK_KEY, JSON.stringify(pending)); } catch (e) { console.error('FightCapture: Error storing pending attack', e); } } /** * Retrieves and clears pending attack */ function getPendingAttack() { try { const data = sessionStorage.getItem(PENDING_ATTACK_KEY); if (!data) return null; const pending = JSON.parse(data); // Only valid for 5 minutes if (Date.now() - pending.timestamp > 5 * 60 * 1000) { sessionStorage.removeItem(PENDING_ATTACK_KEY); return null; } return pending; } catch (e) { console.error('FightCapture: Error getting pending attack', e); return null; } } /** * Clears pending attack */ function clearPendingAttack() { try { sessionStorage.removeItem(PENDING_ATTACK_KEY); } catch (e) { // Ignore } } /** * Checks if current page is an attack result page * * FIXED v2.1.7: Only process attackLog pages to prevent duplicate logging. * Previously checked both sid=attack AND sid=attackLog, which caused wins/losses * to be logged twice (once on attack page, once on attackLog page). * Now only processes the final attackLog page where complete results are shown. */ function isAttackResultPage() { // ONLY process the attackLog page (after fight completes) // Do NOT process sid=attack pages (fight in progress or just finished) return location.href.includes('sid=attackLog'); } /** * Gets the unique attack log ID from URL */ function getAttackLogId() { // Extract the hash ID from attackLog URLs like: // loader.php?sid=attackLog&ID=3f7eea8c1697b01e3740d3b3c5062f55 const match = location.href.match(/sid=attackLog[^&]*&ID=([a-f0-9]+)/i); return match ? match[1] : null; } /** * Checks if this attack log has already been processed */ function isAttackLogProcessed(logId) { if (!logId) return false; try { const processed = JSON.parse(localStorage.getItem('ff_processed_logs') || '[]'); return processed.includes(logId); } catch (e) { return false; } } /** * Marks an attack log as processed */ function markAttackLogProcessed(logId) { if (!logId) return; try { let processed = JSON.parse(localStorage.getItem('ff_processed_logs') || '[]'); if (!processed.includes(logId)) { processed.push(logId); // Keep only last 200 to prevent localStorage bloat if (processed.length > 200) { processed = processed.slice(-200); } localStorage.setItem('ff_processed_logs', JSON.stringify(processed)); } } catch (e) { console.error('FightCapture: Error marking log processed', e); } } /** * Checks if current page is the attack log page */ function isAttackLogPage() { return location.href.includes('attacklog.php') || location.href.includes('page.php?sid=attackLog'); } /** * Parses the attack outcome from the page */ function parseAttackOutcome() { const bodyText = document.body.innerText || ''; const htmlText = document.body.innerHTML || ''; // PATCHED: Check for "You lost" or "YOU LOST" anywhere in the DOM // This is more robust than relying on specific class names const allElements = document.querySelectorAll('*'); for (const el of allElements) { const text = el.textContent?.trim(); // Look for exact "You lost" or "YOU LOST" as the only/main content if (text && (text === 'You lost' || text === 'YOU LOST' || text === 'you lost')) { console.log('FightCapture: LOSS detected via DOM element with exact text match'); return 'loss'; } } // CRITICAL: Check LOSS patterns FIRST to prevent false positives from win patterns // Loss conditions - EXPANDED PATTERNS if (/You lost/i.test(bodyText) || /YOU LOST/i.test(bodyText) || /You were defeated/i.test(bodyText) || /defeated you/i.test(bodyText) || /you were beaten/i.test(bodyText) || /lost the fight/i.test(bodyText) || /and hospitalized you/i.test(bodyText) || /hospitalized you for\s+\d+/i.test(bodyText) || /put you in the hospital/i.test(bodyText) || /put you in hospital/i.test(bodyText) || /sent you to the hospital/i.test(bodyText) || /You were hospitalized for/i.test(bodyText) || /sent to hospital for/i.test(bodyText) || /lost to\s+\w+/i.test(bodyText)) { console.log('FightCapture: LOSS detected via text pattern'); return 'loss'; } // Also check HTML for "You lost" in case it's not in innerText if (/You\s+lost/i.test(htmlText) || /YOU\s+LOST/i.test(htmlText)) { console.log('FightCapture: LOSS detected in HTML'); return 'loss'; } // Win conditions - RESTORED BROADER PATTERNS while keeping loss-first logic if (/You attacked\s+.+\s+and won/i.test(bodyText) || /executing\s+.+\s+for\s+\d+/i.test(bodyText) || // RESTORED: Matches "executing KoTomka for 173" /hospitalized\s+.+\s+for\s+\d+/i.test(bodyText) || // RESTORED: Broad hospitalization pattern /You mugged/i.test(bodyText) || /You have taken\s+.+\s+from/i.test(bodyText) || /left\s+.+\s+on the street/i.test(bodyText) || // RESTORED: Removed "You" requirement /left them on the street/i.test(bodyText) || /defeated\s+/i.test(bodyText) || // RESTORED: Broad defeated pattern /won the fight/i.test(bodyText) || /knocked out/i.test(bodyText) || // RESTORED: Removed "You" requirement /beat\s+.+\s+(up|down)/i.test(bodyText)) { // RESTORED: Removed "You" requirement console.log('FightCapture: WIN detected via text pattern'); return 'win'; } // Stalemate if (/stalemate/i.test(bodyText) || /draw/i.test(bodyText)) { return 'stalemate'; } // Escape if (/escaped/i.test(bodyText) || /ran away/i.test(bodyText) || /fled/i.test(bodyText)) { return 'escape'; } // Interrupted/Assist if (/Someone else/i.test(bodyText) || /interrupted/i.test(bodyText) || /assist/i.test(bodyText)) { return 'assist'; } // Timeout if (/timeout/i.test(bodyText) || /timed out/i.test(bodyText)) { return 'timeout'; } return 'unknown'; } /** * Extracts opponent info from attack page */ function extractOpponentFromPage() { try { let opponentId = null; let opponentName = null; // PATCHED: Get current user's ID from API data to prevent self-fights const currentUserId = ME_STATS?.player_id || ME_STATS?.basic?.player_id || null; // First try URL's user2ID parameter (during attack) const urlMatch = location.href.match(/user2ID=(\d+)/i); if (urlMatch) { opponentId = parseInt(urlMatch[1], 10); // PATCHED: Skip if this is the current user (self-fight) if (currentUserId && opponentId === currentUserId) { console.log('FightCapture: Skipping self-fight (detected via URL parameter)'); return { opponentId: null, opponentName: null }; } } // For attackLog pages, we need to find the opponent from page content // The opponent is the other person in the fight (not the current user) if (!opponentId) { // Get current user's name to exclude them const currentUserEl = document.querySelector('#sidebarroot a[href*="profiles.php"]') || document.querySelector('[class*="user-name"]') || document.querySelector('.info-name'); const currentUserName = currentUserEl?.textContent?.trim()?.toLowerCase(); // Find all profile links in the attack log const profileLinks = document.querySelectorAll('a[href*="profiles.php?XID="]'); for (const link of profileLinks) { const match = link.href.match(/XID=(\d+)/); const linkName = link.textContent?.trim(); if (match) { const id = parseInt(match[1], 10); // Validate it's a real user ID (not a tiny number) if (id <= 100) continue; // PATCHED: Skip if this is the current user (by ID) if (currentUserId && id === currentUserId) { console.log('FightCapture: Skipping self (by ID):', id); continue; } // Skip if this is the current user (by name - fallback) if (currentUserName && linkName?.toLowerCase() === currentUserName) { console.log('FightCapture: Skipping self (by name):', linkName); continue; } opponentId = id; opponentName = linkName; break; } } } // Try title as fallback for name if (opponentId && !opponentName) { const titleMatch = document.title.match(/attacking\s+(.+)/i); if (titleMatch) { opponentName = titleMatch[1].trim(); } } // PATCHED: Final safety check - prevent self-fight if (opponentId && currentUserId && opponentId === currentUserId) { console.log('FightCapture: Final check - preventing self-fight'); return { opponentId: null, opponentName: null }; } console.log('FightCapture: Extracted opponent - ID:', opponentId, 'Name:', opponentName); return { opponentId, opponentName }; } catch (e) { console.error('FightCapture: Error extracting opponent info', e); return { opponentId: null, opponentName: null }; } } /** * Processes attack result and logs it */ let _processedThisPage = false; async function processAttackResult() { // Strict guards to prevent duplicate logging if (_processedThisPage) return; if (!isAttackResultPage()) return; // Check if this specific attack log has already been processed (persists across refreshes) const logId = getAttackLogId(); if (logId && isAttackLogProcessed(logId)) { console.log('FightCapture: Attack log already processed:', logId); _processedThisPage = true; // Prevent further attempts this session return; } console.log('FightCapture: Detected attack page, waiting for content...'); // Wait for page to fully load await new Promise(resolve => setTimeout(resolve, 2500)); // Double-check we haven't processed while waiting if (_processedThisPage) return; // Re-check persistent storage after wait if (logId && isAttackLogProcessed(logId)) { console.log('FightCapture: Attack log already processed (after wait):', logId); _processedThisPage = true; return; } // PATCHED: Try multiple times if outcome is unknown (handles late-loading modals) let outcome = 'unknown'; let attempts = 0; const maxAttempts = 5; while (outcome === 'unknown' && attempts < maxAttempts) { outcome = parseAttackOutcome(); if (outcome === 'unknown') { console.log(`FightCapture: Attempt ${attempts + 1}/${maxAttempts} - outcome still unknown, waiting...`); await new Promise(resolve => setTimeout(resolve, 1000)); attempts++; } else { console.log(`FightCapture: Outcome detected on attempt ${attempts + 1}: ${outcome}`); } } console.log('FightCapture: Final parsed outcome:', outcome); if (outcome === 'unknown') { console.log('FightCapture: Could not determine outcome from page text after', maxAttempts, 'attempts'); console.log('FightCapture: Page sample:', document.body.innerText?.substring(0, 300)); return; } const pending = getPendingAttack(); const pageInfo = extractOpponentFromPage(); console.log('FightCapture: Pending attack data:', pending); console.log('FightCapture: Page info:', pageInfo); // Build fight record const fightRecord = { opponentId: pending?.opponentId || pageInfo.opponentId, opponentName: pending?.opponentName || pageInfo.opponentName, outcome: outcome, rsiRaw: pending?.rsiRaw || null, rsiAdjusted: pending?.rsiAdjusted || null, riskClass: pending?.riskClass || null, oppLife: pending?.oppLife || null, oppLevel: pending?.oppLevel || null, oppSnapshot: pending?.oppSnapshot || null, hadPrediction: !!pending, attackLogId: logId, // Store the log ID for reference capturedAt: Date.now() }; // Only log if we have a VALID opponent ID (not just any number) if (!fightRecord.opponentId || fightRecord.opponentId < 100) { console.log('FightCapture: Invalid opponent ID:', fightRecord.opponentId, '- skipping log'); return; } // Mark as processed BEFORE async operations (both session and persistent) _processedThisPage = true; if (logId) { markAttackLogProcessed(logId); } try { await FightDB.logFight(fightRecord); clearPendingAttack(); // Auto-update learned RSI if we have RSI data if (fightRecord.rsiAdjusted || fightRecord.rsiRaw) { const rsiValue = fightRecord.rsiAdjusted || fightRecord.rsiRaw; const isWin = outcome === 'win'; FightDB.updateLearnedRSI(rsiValue, isWin); console.log('FightCapture: Auto-updated Win Chance model with', { rsiValue, isWin }); } showNotification(`📊 Fight logged: ${outcome.toUpperCase()}`); console.log('FightCapture: Successfully logged fight', fightRecord); } catch (e) { console.error('FightCapture: Failed to log fight', e); showNotification('Failed to log fight: ' + e.message, true); // Note: We don't reset _processedThisPage here because we already marked it in persistent storage } } /** * Parses fights from the attack log page */ function parseAttackLogEntries() { const entries = []; // Look for attack log rows document.querySelectorAll('[class*="log-list"] li, .attack-log-item, [class*="attackLog"] [class*="row"]').forEach(row => { const text = row.innerText || ''; // Skip if already processed if (row.dataset.ffProcessed) return; // Try to extract opponent ID from links const profileLink = row.querySelector('a[href*="profiles.php?XID="]'); const opponentId = profileLink?.href.match(/XID=(\d+)/)?.[1]; const opponentName = profileLink?.textContent?.trim(); if (!opponentId) return; let outcome = null; if (/left\s+.+\s+on the street/i.test(text) || /hospitalized/i.test(text) || /mugged/i.test(text) || /won/i.test(text)) { outcome = 'win'; } else if (/lost/i.test(text) || /hospitalized you/i.test(text) || /defeated you/i.test(text)) { outcome = 'loss'; } if (outcome) { entries.push({ opponentId: parseInt(opponentId, 10), opponentName, outcome, source: 'attacklog' }); row.dataset.ffProcessed = '1'; } }); return entries; } /** * Sets up attack page observer */ function setupAttackPageObserver() { // Only run on attack result pages if (!isAttackResultPage()) return; // Track if we've already set up observer if (window.__ff_observer_setup) return; window.__ff_observer_setup = true; console.log('FightCapture: Attack result page detected at', location.href); // Process after a delay to let page load setTimeout(() => processAttackResult(), 3000); // Also observe for late-loading content, but with limited retries let retryCount = 0; const observer = new MutationObserver(() => { if (retryCount < 2 && !_processedThisPage) { retryCount++; setTimeout(() => processAttackResult(), 1000); } }); observer.observe(document.body, { childList: true, subtree: true }); // Stop observing after 15 seconds setTimeout(() => observer.disconnect(), 15000); } return { storePendingAttack, getPendingAttack, clearPendingAttack, isAttackResultPage, isAttackLogPage, parseAttackOutcome, processAttackResult, setupAttackPageObserver, parseAttackLogEntries }; })(); // ============================================================================ // ATK SCOUTER v1.8.0 - RSI CACHE MODULE // Standalone module for persistent RSI caching with IndexedDB // INSERT THIS MODULE AFTER LINE 2227 (after FightCapture) and BEFORE ApiManager // ============================================================================ // ============================================================================ // API BUDGET TRACKER // ============================================================================ const APIBudgetTracker = (() => { const limit = 100; // Per minute const window = 60000; // 1 minute in ms let calls = []; // Array of timestamps return { canMakeCall() { const now = Date.now(); calls = calls.filter(t => now - t < window); return calls.length < limit; }, getRemainingBudget() { const now = Date.now(); calls = calls.filter(t => now - t < window); return limit - calls.length; }, getUsedBudget() { const now = Date.now(); calls = calls.filter(t => now - t < window); return calls.length; }, recordCall() { calls.push(Date.now()); }, getStats() { const now = Date.now(); calls = calls.filter(t => now - t < window); return { limit: limit, used: calls.length, remaining: limit - calls.length, percentage: Math.round((calls.length / limit) * 100) }; } }; })(); // ============================================================================ // RSI CACHE MANAGER (IndexedDB persistent storage) // ============================================================================ const RSICacheManager = (() => { let db = null; let isInitialized = false; let refreshQueue = []; let isProcessingQueue = false; // Cache statistics let stats = { totalHits: 0, totalMisses: 0, totalRefreshes: 0, apiCallsSaved: 0 }; // Load stats from localStorage const savedStats = Env.getValue('rsiCacheStats', null); if (savedStats) { stats = savedStats; } function saveStats() { Env.setValue('rsiCacheStats', stats); } /** * Initialize IndexedDB */ async function init() { if (isInitialized) return true; return new Promise((resolve, reject) => { const request = indexedDB.open(RSI_CACHE_DB_NAME, RSI_CACHE_DB_VERSION); request.onerror = () => { console.error('RSI Cache: Failed to open IndexedDB', request.error); reject(request.error); }; request.onsuccess = () => { db = request.result; isInitialized = true; console.log('RSI Cache: IndexedDB initialized successfully'); resolve(true); }; request.onupgradeneeded = (event) => { const database = event.target.result; // Create object store if it doesn't exist if (!database.objectStoreNames.contains(RSI_CACHE_STORE_NAME)) { const objectStore = database.createObjectStore(RSI_CACHE_STORE_NAME, { keyPath: 'userId' }); objectStore.createIndex('timestamp', 'timestamp', { unique: false }); objectStore.createIndex('refreshPriority', 'refreshPriority', { unique: false }); console.log('RSI Cache: Created object store'); } }; }); } /** * Get cache confidence level based on age */ function getCacheConfidence(timestamp) { const age = Date.now() - timestamp; if (age < CACHE_TIERS.FRESH) return 'high'; if (age < CACHE_TIERS.MODERATE) return 'medium'; return 'low'; } /** * Get cached RSI data for a user */ async function get(userId) { if (!settings.rsiCacheEnabled) return null; if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([RSI_CACHE_STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(RSI_CACHE_STORE_NAME); const request = objectStore.get(parseInt(userId, 10)); request.onsuccess = () => { const data = request.result; if (!data) { stats.totalMisses++; saveStats(); resolve(null); return; } // Check if expired const age = Date.now() - data.timestamp; if (age > CACHE_TIERS.STALE) { stats.totalMisses++; saveStats(); // Delete expired entry deleteEntry(userId); resolve(null); return; } // Cache hit! stats.totalHits++; stats.apiCallsSaved++; saveStats(); // Queue for background refresh if stale if (settings.rsiCacheBackgroundRefresh && age > CACHE_TIERS.MODERATE) { queueBackgroundRefresh(userId, data.refreshPriority || REFRESH_PRIORITY.RANDOM_BOUNTY); } resolve(data); }; request.onerror = () => { console.error('RSI Cache: Error reading from cache', request.error); resolve(null); }; }); } /** * Store RSI data in cache */ async function set(userId, apiData, priority = REFRESH_PRIORITY.RANDOM_BOUNTY) { if (!settings.rsiCacheEnabled) return; if (!isInitialized) await init(); // Calculate RSI from API data const rsi = calcRSI(USER_BP, apiData.personalstats, apiData.basic, apiData.life); const cacheEntry = { userId: parseInt(userId, 10), timestamp: Date.now(), // Raw API data personalstats: apiData.personalstats, basic: apiData.basic, life: apiData.life, profile: apiData.profile, name: apiData.name || apiData.basic?.name, level: apiData.level || apiData.basic?.level, // Pre-calculated RSI values rsiRaw: rsi.raw, rsiAdjusted: rsi.adjusted, woundPenalty: rsi.woundPenalty, boost: rsi.boost, lifeRatio: rsi.lifeRatio, oppBP: rsi.oppBP, // Metadata viewCount: 1, lastFoughtTimestamp: 0, refreshPriority: priority }; return new Promise((resolve) => { const transaction = db.transaction([RSI_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(RSI_CACHE_STORE_NAME); // Get existing entry to preserve view count and last fought const getRequest = objectStore.get(parseInt(userId, 10)); getRequest.onsuccess = () => { const existing = getRequest.result; if (existing) { cacheEntry.viewCount = (existing.viewCount || 0) + 1; cacheEntry.lastFoughtTimestamp = existing.lastFoughtTimestamp || 0; } const putRequest = objectStore.put(cacheEntry); putRequest.onsuccess = () => { resolve(true); }; putRequest.onerror = () => { console.error('RSI Cache: Error storing in cache', putRequest.error); resolve(false); }; }; }); } /** * Update last fought timestamp (for priority calculation) */ async function updateLastFought(userId) { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([RSI_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(RSI_CACHE_STORE_NAME); const request = objectStore.get(parseInt(userId, 10)); request.onsuccess = () => { const data = request.result; if (data) { data.lastFoughtTimestamp = Date.now(); data.refreshPriority = REFRESH_PRIORITY.RECENT_TARGET; objectStore.put(data); } resolve(); }; request.onerror = () => resolve(); }); } /** * Delete a single cache entry */ async function deleteEntry(userId) { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([RSI_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(RSI_CACHE_STORE_NAME); const request = objectStore.delete(parseInt(userId, 10)); request.onsuccess = () => resolve(true); request.onerror = () => resolve(false); }); } /** * Clear entire cache */ async function clearAll() { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([RSI_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(RSI_CACHE_STORE_NAME); const request = objectStore.clear(); request.onsuccess = () => { console.log('RSI Cache: All entries cleared'); // Reset stats stats = { totalHits: 0, totalMisses: 0, totalRefreshes: 0, apiCallsSaved: 0 }; saveStats(); resolve(true); }; request.onerror = () => { console.error('RSI Cache: Error clearing cache', request.error); resolve(false); }; }); } /** * Get cache statistics */ async function getStats() { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([RSI_CACHE_STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(RSI_CACHE_STORE_NAME); const countRequest = objectStore.count(); countRequest.onsuccess = () => { const totalPlayers = countRequest.result; // Get oldest entry const cursorRequest = objectStore.index('timestamp').openCursor(); let oldestTimestamp = null; cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { oldestTimestamp = cursor.value.timestamp; } const totalCalls = stats.totalHits + stats.totalMisses; const hitRate = totalCalls > 0 ? ((stats.totalHits / totalCalls) * 100).toFixed(1) : '0.0'; let oldestAge = 'None'; if (oldestTimestamp) { const age = Date.now() - oldestTimestamp; const days = Math.floor(age / (24 * 60 * 60 * 1000)); const hours = Math.floor((age % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); if (days > 0) { oldestAge = `${days}d ${hours}h`; } else { oldestAge = `${hours}h`; } } resolve({ totalPlayers, cacheHitRate: hitRate + '%', apiCallsSaved: stats.apiCallsSaved, oldestEntry: oldestAge, totalHits: stats.totalHits, totalMisses: stats.totalMisses, refreshQueueSize: refreshQueue.length }); }; cursorRequest.onerror = () => { resolve({ totalPlayers, cacheHitRate: '0%', apiCallsSaved: stats.apiCallsSaved, oldestEntry: 'Error', totalHits: stats.totalHits, totalMisses: stats.totalMisses, refreshQueueSize: refreshQueue.length }); }; }; countRequest.onerror = () => { resolve({ totalPlayers: 0, cacheHitRate: '0%', apiCallsSaved: 0, oldestEntry: 'Error', totalHits: 0, totalMisses: 0, refreshQueueSize: 0 }); }; }); } /** * Queue a user for background refresh */ function queueBackgroundRefresh(userId, priority = REFRESH_PRIORITY.RANDOM_BOUNTY) { // Don't queue if already in queue if (refreshQueue.some(item => item.userId === userId)) { return; } refreshQueue.push({ userId: parseInt(userId, 10), priority, timestamp: Date.now() }); // Sort queue by priority refreshQueue.sort((a, b) => a.priority - b.priority); // Start processing if not already running if (!isProcessingQueue) { processRefreshQueue(); } } /** * Process background refresh queue */ async function processRefreshQueue() { if (isProcessingQueue || refreshQueue.length === 0) { return; } isProcessingQueue = true; while (refreshQueue.length > 0) { // Check if we have API budget const remaining = APIBudgetTracker.getRemainingBudget(); if (remaining < 10) { // Wait if budget is low console.log('RSI Cache: Pausing background refresh - low API budget'); await new Promise(resolve => setTimeout(resolve, 10000)); continue; } const item = refreshQueue.shift(); try { // Fetch fresh data from API await new Promise((resolve) => { ApiManager.get(`/user/${item.userId}?selections=personalstats,basic,profile`, async (data) => { if (data && !data.error) { await set(item.userId, data, item.priority); stats.totalRefreshes++; saveStats(); console.log(`RSI Cache: Background refresh completed for user ${item.userId}`); } resolve(); }); }); // Small delay between refreshes await new Promise(resolve => setTimeout(resolve, 300)); } catch (e) { console.error('RSI Cache: Background refresh error', e); } } isProcessingQueue = false; } /** * Batch get multiple users (for faction pages) */ async function batchGet(userIds) { if (!isInitialized) await init(); const results = {}; const promises = userIds.map(async (userId) => { const data = await get(userId); if (data) { results[userId] = data; } }); await Promise.all(promises); return results; } // Initialize on load init().catch(e => console.error('RSI Cache: Init error', e)); return { init, get, set, deleteEntry, clearAll, getStats, getCacheConfidence, updateLastFought, queueBackgroundRefresh, batchGet }; })(); // ============================================================================ // STATUS CACHE MANAGER (NEW - Short-term status caching) // ============================================================================ const StatusCacheManager = (() => { let db = null; let isInitialized = false; const STATUS_CACHE_TTL = 180 * 1000; // 3 minutes (matches refresh interval) const STATUS_CACHE_DB_NAME = 'ATKScouterStatusCache'; const STATUS_CACHE_DB_VERSION = 1; const STATUS_CACHE_STORE_NAME = 'statusData'; // Queue for background status fetches let statusQueue = []; let isProcessingStatusQueue = false; /** * Initialize Status Cache DB */ async function init() { if (isInitialized) return true; return new Promise((resolve, reject) => { const request = indexedDB.open(STATUS_CACHE_DB_NAME, STATUS_CACHE_DB_VERSION); request.onerror = () => { console.error('Status Cache: Failed to open IndexedDB', request.error); reject(request.error); }; request.onsuccess = () => { db = request.result; isInitialized = true; console.log('Status Cache: IndexedDB initialized'); resolve(true); }; request.onupgradeneeded = (event) => { const database = event.target.result; if (!database.objectStoreNames.contains(STATUS_CACHE_STORE_NAME)) { const objectStore = database.createObjectStore(STATUS_CACHE_STORE_NAME, { keyPath: 'userId' }); objectStore.createIndex('timestamp', 'timestamp', { unique: false }); objectStore.createIndex('hasActiveStatus', 'hasActiveStatus', { unique: false }); console.log('Status Cache: Created object store'); } }; }); } /** * Check if player has active status (traveling or in hospital) */ function hasActiveStatus(statusData) { if (!statusData) return false; const status = statusData.status; const travel = statusData.travel; // Check status.state first (most reliable indicator from profile selection) if (status && status.state) { const state = status.state; // Hospital if (state === 'Hospital') return true; // Traveling/Abroad if (state === 'Traveling' || state === 'Abroad') return true; // Jail/Federal if (state === 'Jail' || state === 'Federal') return true; } // Fallback: Check travel object (in case it's available) if (travel && travel.time_left > 0) return true; return false; } /** * Get cached status for a user */ async function get(userId) { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([STATUS_CACHE_STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(STATUS_CACHE_STORE_NAME); const request = objectStore.get(parseInt(userId, 10)); request.onsuccess = () => { const data = request.result; if (!data) { resolve(null); return; } // Check if expired const age = Date.now() - data.timestamp; if (age > STATUS_CACHE_TTL) { deleteEntry(userId); resolve(null); return; } resolve(data); }; request.onerror = () => { resolve(null); }; }); } /** * Store status data (only if player has active status) */ async function set(userId, apiData) { if (!isInitialized) await init(); // Extract status data from API response // When using profile selection, status is in data.status const statusData = { status: apiData.status, travel: apiData.travel, timestamp: apiData.timestamp || (apiData.last_action ? apiData.last_action.timestamp : Date.now() / 1000) }; const hasStatus = hasActiveStatus(statusData); // Debug logging console.log(`Status Cache: User ${userId} status check:`, { state: statusData.status?.state, hasStatus: hasStatus, travel_time_left: statusData.travel?.time_left, hospital_until: statusData.status?.until }); // Only cache if player has active status if (!hasStatus) { // Remove from cache if they no longer have status await deleteEntry(userId); console.log(`Status Cache: User ${userId} has no active status - removed from cache`); return false; } const cacheEntry = { userId: parseInt(userId, 10), timestamp: Date.now(), status: statusData.status, travel: statusData.travel, apiTimestamp: statusData.timestamp, hasActiveStatus: true }; return new Promise((resolve) => { const transaction = db.transaction([STATUS_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STATUS_CACHE_STORE_NAME); const putRequest = objectStore.put(cacheEntry); putRequest.onsuccess = () => { console.log(`Status Cache: ✓ STORED user ${userId}:`, { state: statusData.status?.state, destination: statusData.travel?.destination, hasStatus: hasActiveStatus(statusData) }); resolve(true); }; putRequest.onerror = () => { resolve(false); }; }); } /** * Delete a status cache entry */ async function deleteEntry(userId) { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([STATUS_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STATUS_CACHE_STORE_NAME); const request = objectStore.delete(parseInt(userId, 10)); request.onsuccess = () => resolve(true); request.onerror = () => resolve(false); }); } /** * Get all users who recently had active status */ async function getUsersWithRecentStatus() { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([STATUS_CACHE_STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(STATUS_CACHE_STORE_NAME); const request = objectStore.getAll(); // Get all, we'll filter manually request.onsuccess = () => { const results = request.result || []; // Filter to only entries with hasActiveStatus === true const activeUsers = results.filter(entry => entry.hasActiveStatus === true); const userIds = activeUsers.map(entry => entry.userId); console.log(`Status Cache: Found ${userIds.length} users with recent status`); resolve(userIds); }; request.onerror = () => { console.error('Status Cache: Error getting users with recent status'); resolve([]); }; }); } /** * Queue users for background status check */ function queueStatusCheck(userIds) { if (!Array.isArray(userIds)) { userIds = [userIds]; } userIds.forEach(userId => { if (!statusQueue.some(id => id === userId)) { statusQueue.push(parseInt(userId, 10)); } }); // Start processing if (!isProcessingStatusQueue) { processStatusQueue(); } } /** * Process background status check queue */ async function processStatusQueue() { if (isProcessingStatusQueue || statusQueue.length === 0) { return; } isProcessingStatusQueue = true; console.log(`Status Cache: Processing queue (${statusQueue.length} users)`); while (statusQueue.length > 0) { // Check API budget const remaining = APIBudgetTracker.getRemainingBudget(); if (remaining < 20) { console.log('Status Cache: Pausing - low API budget'); await new Promise(resolve => setTimeout(resolve, 5000)); continue; } // Process in batch of 10 const batch = statusQueue.splice(0, 10); await Promise.all(batch.map(userId => { return new Promise((resolve) => { // Lightweight API call - only status, travel, timestamp // Use profile selection which includes status, travel, timestamp ApiManager.get(`/user/${userId}?selections=profile`, async (data) => { if (data && !data.error) { await set(userId, data); // Update UI if this user is visible updateStatusIndicatorsForUser(userId, data); } resolve(); }); }); })); // Small delay between batches await new Promise(resolve => setTimeout(resolve, 200)); } isProcessingStatusQueue = false; console.log('Status Cache: Queue processing complete'); // Debug: dump cache contents to verify what was stored debugDump(); } /** * Update status indicators for a specific user in the DOM */ function updateStatusIndicatorsForUser(userId, statusData) { console.log(`[updateStatusIndicatorsForUser] Updating UI for user ${userId}`); // Find all elements for this user const elements = document.querySelectorAll(`[data-user-id="${userId}"]`); if (elements.length === 0) { console.log(`[updateStatusIndicatorsForUser] No elements found for user ${userId}`); return; } elements.forEach(el => { // Use the existing addStatusIcon function which properly handles all status types addStatusIcon(statusData, el, userId); }); } /** * Clear entire status cache */ async function clearAll() { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([STATUS_CACHE_STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STATUS_CACHE_STORE_NAME); const request = objectStore.clear(); request.onsuccess = () => { console.log('Status Cache: Cleared all entries'); resolve(true); }; request.onerror = () => { resolve(false); }; }); } /** * Debug function - dump all cache entries to console */ async function debugDump() { if (!isInitialized) await init(); return new Promise((resolve) => { const transaction = db.transaction([STATUS_CACHE_STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(STATUS_CACHE_STORE_NAME); const request = objectStore.getAll(); request.onsuccess = () => { const entries = request.result || []; console.log(`===== STATUS CACHE DEBUG DUMP (${entries.length} entries) =====`); if (entries.length === 0) { console.log('No entries in cache'); } else { entries.forEach(entry => { const age = Math.floor((Date.now() - entry.timestamp) / 1000); console.log(`User ${entry.userId}: ${entry.status?.state} at ${entry.travel?.destination || 'N/A'} (${age}s old)`); }); } console.log('===================================================='); resolve(entries); }; request.onerror = () => { console.error('Failed to dump cache'); resolve([]); }; }); } // Initialize on load init().catch(e => console.error('Status Cache: Init error', e)); return { init, get, set, deleteEntry, clearAll, getUsersWithRecentStatus, queueStatusCheck, hasActiveStatus, debugDump }; })(); /** * Last Check Cache Manager * Tracks when we last checked each user's status (even "Okay" users) * This prevents unnecessary refreshes on page reload */ const LastCheckCacheManager = (() => { const CACHE_KEY_PREFIX = 'ff_last_status_check_'; const CHECK_INTERVAL = 180000; // 3 minutes function set(userId) { try { localStorage.setItem(CACHE_KEY_PREFIX + userId, Date.now().toString()); } catch (e) { console.warn('LastCheckCache: Failed to set', e); } } function get(userId) { try { const timestamp = localStorage.getItem(CACHE_KEY_PREFIX + userId); return timestamp ? parseInt(timestamp, 10) : 0; } catch (e) { console.warn('LastCheckCache: Failed to get', e); return 0; } } function shouldCheck(userId) { const lastCheck = get(userId); const timeSinceCheck = Date.now() - lastCheck; return timeSinceCheck > CHECK_INTERVAL; } function getTimeSinceCheck(userId) { const lastCheck = get(userId); if (!lastCheck) return 999999; return Date.now() - lastCheck; } function clearAll() { try { const keys = Object.keys(localStorage); keys.forEach(key => { if (key.startsWith(CACHE_KEY_PREFIX)) { localStorage.removeItem(key); } }); console.log('LastCheckCache: Cleared all entries'); } catch (e) { console.warn('LastCheckCache: Failed to clear', e); } } return { set, get, shouldCheck, getTimeSinceCheck, clearAll, CHECK_INTERVAL }; })(); // ============================================================================ // API LAYER (with cache eviction) // ============================================================================ const ApiManager = (() => { const cache = new Map(); const queue = []; const inFlight = new Map(); let active = 0; let lastStart = 0; let timer = null; let errorShown = false; function evictOldEntries() { if (cache.size <= API_CACHE_MAX_SIZE) return; const entries = [...cache.entries()] .sort((a, b) => a[1].ts - b[1].ts); const toRemove = entries.slice(0, Math.floor(cache.size * 0.25)); toRemove.forEach(([key]) => cache.delete(key)); } function showBanner(text) { let el = document.getElementById('ff-api-warning'); if (!el) { el = document.createElement('div'); el.id = 'ff-api-warning'; document.body.appendChild(el); } el.textContent = text + ' (click to hide)'; el.onclick = () => el.style.display = 'none'; el.style.display = 'block'; } function drainQueue() { if (!queue.length || active >= API_MAX_ACTIVE) return; const now = Date.now(); const wait = API_MIN_DELAY - (now - lastStart); if (wait > 0) { if (!timer) { timer = setTimeout(() => { timer = null; drainQueue(); }, wait); } return; } const item = queue.shift(); if (!item) return; const { ep, cbs } = item; inFlight.set(ep, cbs); active++; lastStart = now; Env.httpRequest({ method: 'GET', url: `https://api.torn.com${ep}&key=${API_KEY}`, onload: async r => { active--; const callbacks = inFlight.get(ep) || cbs; inFlight.delete(ep); try { const data = JSON.parse(r.responseText); if (!data.error) { cache.set(ep, { data, ts: Date.now() }); evictOldEntries(); // Store in RSI cache const userMatch = ep.match(/^\/user\/(\d+)\?selections=personalstats,basic/); if (userMatch && settings.rsiCacheEnabled) { const userId = userMatch[1]; await RSICacheManager.set(userId, data, REFRESH_PRIORITY.RANDOM_BOUNTY); console.log(`RSI Cache: Stored user ${userId}`); } callbacks.forEach(fn => { try { fn(data); } catch (e) { console.error(e); } }); } else { console.error('Torn API error', data.error); if (!errorShown) { errorShown = true; let msg = `ATK Scouter: Torn API error ${data.error.code} — ${data.error.error}`; if (data.error.code === 1 || data.error.code === 2) { msg = 'ATK Scouter: Your Torn API key is invalid or lacks required permissions. Open the ⚙️ settings and verify the key.'; } else if (data.error.code === 5) { msg = 'ATK Scouter: Torn API rate limit reached. Some data may be delayed.'; } showBanner(msg); } } } catch (e) { console.error('Torn API parse error', e); } drainQueue(); }, onerror: err => { active--; inFlight.delete(ep); console.error('Torn API request failed', err); drainQueue(); } }); } return { async get(ep, cb) { if (!API_KEY) return; // Check RSI cache first for user API calls const userMatch = ep.match(/^\/user\/(\d+)\?selections=personalstats,basic/); if (userMatch && settings.rsiCacheEnabled) { const userId = userMatch[1]; const cached = await RSICacheManager.get(userId); if (cached) { console.log(`RSI Cache: HIT for user ${userId}`); try { cb(cached); } catch (e) { console.error(e); } return; } } // Record API call APIBudgetTracker.recordCall(); const now = Date.now(); const ttl = /selections=basic/.test(ep) ? API_CACHE_TTL_BASIC : API_CACHE_TTL_DEFAULT; const cached = cache.get(ep); if (cached && now - cached.ts < ttl) { try { cb(cached.data); } catch (e) { console.error(e); } return; } const pending = inFlight.get(ep); if (pending) { pending.push(cb); return; } queue.push({ ep, cbs: [cb] }); drainQueue(); }, clearCache() { cache.clear(); } }; })(); // ============================================================================ // RSI CALCULATION // ============================================================================ /** * Calculates battle power components and total */ function calcBattlePower(personalStats, basic) { const ps = personalStats || {}; const b = basic || {}; const elo = (ps.elo || 0) * 2; const dmg = Math.sqrt((ps.attackdamage || 0) / 1000) * 1.5; const winDiff = Math.max((ps.attackswon || 0) - (ps.attackslost || 0), 0); const win = Math.sqrt(winDiff) * 1.2; const totalAttacks = (ps.attackswon || 0) + (ps.attackslost || 0); const winRate = totalAttacks > 0 ? (ps.attackswon / totalAttacks) * 100 : 0; const critRate = ps.attackhits > 0 ? ((ps.attackcriticalhits || 0) / ps.attackhits) * 100 : 0; const networth = Math.log10((ps.networth || 0) + 1) * 5; const now = Date.now() / 1000; const joined = b.joined || now; const ageDays = (now - joined) / 86400; const age = Math.log10(ageDays + 1) * 5; const activity = Math.log10((ps.useractivity || 0) + 1) * 2; const parts = { elo, dmg, win, winRate, critRate, networth, age, activity }; const sum = elo + dmg + win + winRate + critRate + networth + age + activity; return { parts, sum }; } /** * Calculates RSI percentage */ function calcRSI(userBP, oppPersonalStats, oppBasic, oppLife) { const oppCalc = calcBattlePower(oppPersonalStats, oppBasic); const oppBP = oppCalc.sum * getGymMultiplier(oppPersonalStats?.xantaken || 0); const raw = (userBP / oppBP) * 100; const lifeRatio = (oppLife?.current && oppLife?.maximum) ? oppLife.current / oppLife.maximum : 1; const woundPenalty = 1 - lifeRatio; const fairness = Math.min(raw / 100, 1); const boost = woundPenalty * settings.lifeWeight * fairness; const adjusted = raw * (1 + boost); return { raw, adjusted, woundPenalty, boost, lifeRatio, oppBP, oppCalc }; } // ============================================================================ // TOOLTIP MANAGER // ============================================================================ const Tooltip = (() => { let element = null; function getElement() { if (!element) { element = document.createElement('div'); element.className = 'ff-tooltip-viewport'; document.body.appendChild(element); } return element; } return { show(pageX, pageY, html) { const tt = getElement(); tt.innerHTML = html; // Already sanitized by caller requestAnimationFrame(() => { const rect = tt.getBoundingClientRect(); let left = pageX - rect.width / 2; left = Math.max(4, Math.min(left, document.documentElement.clientWidth - rect.width - 4)); const top = pageY - rect.height - 12; tt.style.left = left + 'px'; tt.style.top = top + 'px'; tt.style.display = 'block'; }); }, hide() { if (element) element.style.display = 'none'; } }; })(); // ============================================================================ // USER STATE // ============================================================================ let ME_STATS = null; let ME_DRUGS = null; let USER_BP = null; let USER_DRUG_DEBUFF = 0; // Global references for RSI+ panel window.__FF_ME_STATS_OBJ = null; window.__FF_LAST_PROFILE_OBJ = null; function initUserState() { ApiManager.get('/user/?selections=personalstats,basic', d => { ME_STATS = d; window.__FF_ME_STATS_OBJ = d; tryInitComplete(); }); ApiManager.get('/user/?selections=battlestats', d => { ME_DRUGS = d; tryInitComplete(); }); } function tryInitComplete() { if (!ME_STATS || !ME_DRUGS) return; const modifiers = [ ME_DRUGS.strength_modifier, ME_DRUGS.defense_modifier, ME_DRUGS.speed_modifier, ME_DRUGS.dexterity_modifier ]; const negatives = modifiers .filter(x => typeof x === 'number' && x < 0) .map(x => -x / 100); USER_DRUG_DEBUFF = negatives.length ? negatives.reduce((a, c) => a + c, 0) / negatives.length : 0; const userCalc = calcBattlePower(ME_STATS.personalstats, ME_STATS.basic); USER_BP = userCalc.sum * getGymMultiplier(ME_STATS.personalstats?.xantaken || 0) * (1 - USER_DRUG_DEBUFF * settings.drugWeight); // Start injection after user state is ready ObserverManager.start(); // Start Bounty Sniper if enabled if (settings.sniperEnabled && API_KEY) { // Small delay to let page settle setTimeout(() => { BountySniper.start(); }, 2000); } // Start Jail Bust Sniper if enabled if (settings.bustSniperEnabled) { // Small delay to let page settle setTimeout(() => { JailBustSniper.start(); }, 2500); } } // ============================================================================ // RSI+ EXPERIMENTAL PANEL // ============================================================================ function buildRSIPlusPanel(headerEl, meStatsObj, oppObj) { try { if (!headerEl || document.getElementById('ff-exp-panel')) return; if (!meStatsObj || !oppObj) return; const mePS = meStatsObj.personalstats || {}; const meB = meStatsObj.basic || {}; const oppPS = oppObj.personalstats || {}; const oppB = oppObj.basic || oppObj.profile || {}; const me = calcBattlePower(mePS, meB); const op = calcBattlePower(oppPS, oppB); const meGymMul = getGymMultiplier(mePS.xantaken || 0); const meDrugMul = 1 - USER_DRUG_DEBUFF * settings.drugWeight; const opGymMul = getGymMultiplier(oppPS.xantaken || 0); const keys = ['elo', 'dmg', 'win', 'winRate', 'critRate', 'networth', 'age', 'activity']; const nameMap = { elo: 'ELO×2', dmg: '√Damage×1.5', win: '√(W−L)×1.2', winRate: 'Win%×100', critRate: 'Crit%×100', networth: 'log₁₀(NW+1)×5', age: 'log Age×5', activity: 'log Activity×2' }; const efficiencies = keys.map(k => { const you = me.parts[k] * meGymMul * meDrugMul; const opp = op.parts[k] * opGymMul; return { key: k, you, opp, delta: you - opp }; }).sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); const oppBP = op.sum * opGymMul; const yourBP = (typeof USER_BP === 'number' && USER_BP > 0) ? USER_BP : (me.sum * meGymMul * meDrugMul); const raw = (yourBP / oppBP) * 100; const life = (oppObj.life?.maximum) ? oppObj.life.current / oppObj.life.maximum : 1; const wp = 1 - life; const fair = Math.min(raw / 100, 1); const boost = wp * settings.lifeWeight * fair; const adj = raw * (1 + boost); // Win Chance model: logistic regression on RSI let theta0 = Number(Env.getValue('ff_lrsi_theta0', 0)); let theta1 = Number(Env.getValue('ff_lrsi_theta1', 0.12)); const xRaw = raw - 100; const pWin = 1 / (1 + Math.exp(-(theta0 + theta1 * xRaw))); const top3 = efficiencies.slice(0, 3).map(r => { const sign = r.delta >= 0 ? '+' : '−'; return `${nameMap[r.key]} ${sign}${Math.abs(r.delta).toFixed(1)}`; }).join(' · '); const wrap = document.createElement('details'); wrap.id = 'ff-exp-panel'; wrap.open = false; wrap.style.cssText = 'margin-top:6px;background:rgba(0,0,0,0.25);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:8px 10px;color:#e6e6e6;'; const summary = document.createElement('summary'); summary.textContent = 'RSI+ Details'; summary.style.cssText = 'cursor:pointer;color:#e6e6e6;font-weight:600;font-size:12px;'; wrap.appendChild(summary); const div = document.createElement('div'); div.innerHTML = `
Adjusted RSI: ${adj.toFixed(2)}% Win Chance (Learned): ${(pWin * 100).toFixed(1)}%
Learning controls: θ0=, θ1= Softer Steeper Reset Re-fit (from log) Clear log Log Win Log Loss
Top drivers: ${escapeHtml(top3)}
Raw: ${raw.toFixed(2)}% · Wound boost: ${(boost * 100).toFixed(1)}% · Life: ${(life * 100).toFixed(0)}%
${efficiencies.map(r => ``).join('')}
Term You Target Δ
${escapeHtml(nameMap[r.key])} ${r.you.toFixed(1)} ${r.opp.toFixed(1)} ${r.delta >= 0 ? '+' : ''}${r.delta.toFixed(1)}
Gym × Drug multipliers applied × ${(meGymMul * meDrugMul).toFixed(3)} × ${opGymMul.toFixed(3)}
`; wrap.appendChild(div); headerEl.insertAdjacentElement('afterend', wrap); // Wire up trainer controls setupTrainerControls(wrap, raw); } catch (e) { console.error('RSI+ Panel error:', e); } } function setupTrainerControls(wrap, rawRSI) { const load = () => ({ t0: Number(Env.getValue('ff_lrsi_theta0', 0)), t1: Number(Env.getValue('ff_lrsi_theta1', 0.12)), meta: Env.getValue('ff_lrsi_meta', { n: 0, ts: 0 }) || { n: 0, ts: 0 }, hist: Env.getValue('ff_lrsi_hist', []) || [] }); const saveTheta = (t0, t1) => { Env.setValue('ff_lrsi_theta0', t0); Env.setValue('ff_lrsi_theta1', t1); }; const saveHist = hist => { Env.setValue('ff_lrsi_hist', hist); Env.setValue('ff_lrsi_meta', { n: hist.length, ts: Date.now() }); }; const addSample = (hist, rsi, won) => { const now = Date.now(); const ninetyDays = 90 * 24 * 3600 * 1000; const pruned = (hist || []).filter(h => now - h.ts <= ninetyDays); pruned.push({ ts: now, rsi: Number(rsi) || 0, y: won ? 1 : 0 }); while (pruned.length > 500) pruned.shift(); saveHist(pruned); return pruned; }; const refit = hist => { if (!hist?.length) return null; let t0 = 0; let t1 = Number(Env.getValue('ff_lrsi_theta1', 0.12)); const lr = 1e-3, reg = 1e-4, iters = 250; for (let it = 0; it < iters; it++) { let g0 = 0, g1 = 0; for (const h of hist) { const x = h.rsi - 100; const z = t0 + t1 * x; const p = 1 / (1 + Math.exp(-z)); const e = p - (h.y ? 1 : 0); g0 += e; g1 += e * x; } g0 += reg * t0; g1 += reg * t1; t0 -= lr * g0; t1 -= lr * g1; } saveTheta(t0, t1); return { t0, t1 }; }; const t0El = wrap.querySelector('#ff-theta0'); const t1El = wrap.querySelector('#ff-theta1'); const nEl = wrap.querySelector('#ff-lrsi-n'); const valEl = wrap.querySelector('#ff-lrsi-value'); const msgEl = wrap.querySelector('#ff-lrsi-msg'); const flash = msg => { if (!msgEl) return; msgEl.textContent = ` · ${msg}`; setTimeout(() => { if (msgEl.textContent === ` · ${msg}`) msgEl.textContent = ''; }, 2000); }; const showTheta = () => { const cur = load(); if (t0El) t0El.textContent = cur.t0.toFixed(3); if (t1El) t1El.textContent = cur.t1.toFixed(3); if (nEl) nEl.textContent = ` · samples: ${cur.meta?.n ?? cur.hist?.length ?? 0}`; }; const updateProb = () => { const cur = load(); const x = rawRSI - 100; const p = 1 / (1 + Math.exp(-(cur.t0 + cur.t1 * x))); if (valEl) valEl.textContent = `${(p * 100).toFixed(1)}%`; }; const refreshAll = () => { showTheta(); updateProb(); }; refreshAll(); wrap.querySelector('#ff-softer')?.addEventListener('click', () => { const cur = load(); saveTheta(cur.t0, cur.t1 * 0.9); refreshAll(); flash('slope softer'); }); wrap.querySelector('#ff-steeper')?.addEventListener('click', () => { const cur = load(); saveTheta(cur.t0, cur.t1 * 1.1); refreshAll(); flash('slope steeper'); }); wrap.querySelector('#ff-reset')?.addEventListener('click', () => { saveTheta(0, 0.12); Env.setValue('ff_lrsi_hist', []); Env.setValue('ff_lrsi_meta', { n: 0, ts: Date.now() }); refreshAll(); flash('reset'); }); wrap.querySelector('#ff-refit')?.addEventListener('click', async () => { try { // Pull fights from IndexedDB instead of old manual log const fights = await FightDB.getAllFights(); if (!fights || fights.length === 0) { flash('no fights in database'); return; } // Convert fights to learning format const samples = []; for (const fight of fights) { const rsi = fight.rsiAdjusted || fight.rsiRaw; if (rsi === null || rsi === undefined) continue; if (fight.outcome !== 'win' && fight.outcome !== 'loss') continue; samples.push({ x: rsi - 100, // Center around 100% y: fight.outcome === 'win' ? 1 : 0, ts: fight.capturedAt || Date.now() }); } if (samples.length === 0) { flash('no fights with RSI data'); return; } // Refit using the converted samples const fitted = refit(samples); // Also save samples to the learning history Env.setValue('ff_lrsi_hist', samples); Env.setValue('ff_lrsi_meta', { n: samples.length, ts: Date.now() }); refreshAll(); flash(fitted ? `re-fit from ${samples.length} fights: θ0=${fitted.t0.toFixed(3)}, θ1=${fitted.t1.toFixed(3)}` : 'fit failed'); } catch (e) { console.error('Re-fit error:', e); flash('error: ' + e.message); } }); wrap.querySelector('#ff-clear')?.addEventListener('click', () => { Env.setValue('ff_lrsi_hist', []); Env.setValue('ff_lrsi_meta', { n: 0, ts: Date.now() }); refreshAll(); flash('log cleared'); }); const logSample = won => { const cur = load(); const hist = addSample(cur.hist, rawRSI, won); refreshAll(); flash(`logged ${won ? 'win' : 'loss'} (#${hist.length})`); }; wrap.querySelector('#ff-logwin')?.addEventListener('click', () => logSample(true)); wrap.querySelector('#ff-logloss')?.addEventListener('click', () => logSample(false)); } // ============================================================================ // FIGHT INTELLIGENCE PANEL // ============================================================================ async function buildFightIntelPanel(headerEl, opponentId, opponentName, currentRSI) { try { // Don't duplicate if (document.getElementById('ff-intel-panel')) return; // Get data from database with proper error handling // If database has schema errors (missing indexes), return empty data instead of crashing let opponentHistory = []; let winRateData = { totalFights: 0, wins: 0, losses: 0, winRate: 0, confidence: 'none', rsiRange: '' }; let learnedData = { probability: 0, confidence: 'none', dataPoints: 0 }; try { [opponentHistory, winRateData, learnedData] = await Promise.all([ FightDB.getOpponentHistory(opponentId).catch(e => { console.warn('FightIntel: getOpponentHistory failed (DB schema error?)', e.message); return []; }), FightDB.getWinRateForRSI(currentRSI).catch(e => { console.warn('FightIntel: getWinRateForRSI failed (DB schema error?)', e.message); return { totalFights: 0, wins: 0, losses: 0, winRate: 0, confidence: 'none', rsiRange: '' }; }), Promise.resolve(FightDB.getLearnedWinProbability(currentRSI)) ]); } catch (dbError) { console.warn('FightIntel: Database queries failed, using empty data', dbError.message); // Continue with empty data - panel will show "No data yet" message } // Calculate opponent-specific stats const oppWins = opponentHistory.filter(f => f.outcome === 'win').length; const oppLosses = opponentHistory.filter(f => f.outcome === 'loss').length; const oppTotal = opponentHistory.length; // Determine win chance data const hasLearnedData = learnedData.dataPoints >= 5; const hasHistoricalData = winRateData.totalFights >= 3; let winChance = null; let confidenceClass = 'none'; let confidenceShort = ''; if (hasLearnedData && learnedData.confidence !== 'none') { winChance = learnedData.probability; confidenceClass = learnedData.confidence; confidenceShort = learnedData.confidence === 'high' ? '✓' : learnedData.confidence === 'medium' ? '📊' : '⚠️'; } else if (hasHistoricalData) { winChance = winRateData.winRate; confidenceClass = winRateData.confidence; confidenceShort = winRateData.confidence === 'high' ? '✓' : winRateData.confidence === 'medium' ? '📊' : '⚠️'; } // Build summary line for collapsed state let summaryParts = []; // Win chance summary if (winChance !== null) { const wcColor = winChance >= 70 ? '#4caf50' : winChance >= 50 ? '#f9a825' : '#ef5350'; summaryParts.push(`${winChance.toFixed(0)}% win ${confidenceShort}`); } else { summaryParts.push('No prediction'); } // Opponent history summary if (oppTotal > 0) { const histColor = oppWins > oppLosses ? '#4caf50' : oppLosses > oppWins ? '#ef5350' : '#90a4ae'; summaryParts.push(`${oppWins}W-${oppLosses}L vs ${escapeHtml(opponentName || 'them')}`); } else { summaryParts.push(`Never fought`); } // Create collapsible panel const panel = document.createElement('details'); panel.id = 'ff-intel-panel'; panel.style.cssText = 'margin-top:6px;background:linear-gradient(135deg,rgba(30,30,40,0.92) 0%,rgba(20,20,30,0.92) 100%);border:1px solid rgba(255,255,255,0.1);border-radius:6px;font-size:12px;color:#e6e6e6;'; // Summary header (always visible) const summary = document.createElement('summary'); summary.style.cssText = 'padding:8px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;user-select:none;list-style:none;'; summary.innerHTML = ` ⚔️ Intel — ${summaryParts.join(' · ')} `; panel.appendChild(summary); // Expanded content container const content = document.createElement('div'); content.style.cssText = 'padding:0 12px 10px 12px;border-top:1px solid rgba(255,255,255,0.08);'; let contentHtml = ''; // Win Chance Section (only if data exists) if (winChance !== null) { let valueClass = winChance >= 70 ? 'positive' : winChance >= 50 ? 'warning' : 'danger'; let barColor = winChance >= 70 ? '#4caf50' : winChance >= 50 ? '#f9a825' : '#ef5350'; let confidenceNote = ''; if (confidenceClass === 'low') { confidenceNote = '⚠️ Low confidence — need more fights'; } else if (confidenceClass === 'medium') { confidenceNote = '📊 Medium confidence'; } else if (confidenceClass === 'high') { confidenceNote = '✓ High confidence'; } const dataPointsLabel = hasLearnedData ? `${learnedData.dataPoints} fights` : `${winRateData.totalFights} similar`; contentHtml += `
Est. Win Chance ${winChance.toFixed(1)}%
${dataPointsLabel} ${confidenceNote}
`; } // Historical Win Rate (only show if different from learned data source AND has data) if (hasHistoricalData && hasLearnedData) { let wrColor = winRateData.winRate >= 70 ? '#4caf50' : winRateData.winRate >= 50 ? '#f9a825' : '#ef5350'; contentHtml += `
At RSI ${winRateData.rsiRange} ${winRateData.wins}W ${winRateData.losses}L
`; } // Opponent History Section (only if fought before) if (oppTotal > 0) { let recordColor = oppWins > oppLosses ? '#4caf50' : oppLosses > oppWins ? '#ef5350' : '#90a4ae'; const recentFights = opponentHistory .sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)) .slice(0, 5); let tagsHtml = recentFights.map(f => { const cls = f.outcome === 'win' ? 'background:rgba(76,175,80,0.2);color:#4caf50;border:1px solid rgba(76,175,80,0.3);' : f.outcome === 'loss' ? 'background:rgba(239,83,80,0.2);color:#ef5350;border:1px solid rgba(239,83,80,0.3);' : 'background:rgba(158,158,158,0.2);color:#9e9e9e;'; const txt = f.outcome === 'win' ? 'W' : f.outcome === 'loss' ? 'L' : '?'; return `${txt}`; }).join(''); let lastFightInfo = ''; if (recentFights[0]) { const timeAgo = formatTimeAgo(recentFights[0].capturedAt); lastFightInfo = `Last: ${timeAgo}`; } contentHtml += `
vs ${escapeHtml(opponentName || 'this player')} ${oppWins}W - ${oppLosses}L
${tagsHtml} ${lastFightInfo}
`; } // If no meaningful expanded content, show a minimal message if (!contentHtml) { contentHtml = `
Fight opponents to build intelligence data
`; } content.innerHTML = contentHtml; panel.appendChild(content); // Insert after the h4 header (or after RSI+ panel if it exists) const rsiPlusPanel = document.getElementById('ff-exp-panel'); if (rsiPlusPanel) { rsiPlusPanel.after(panel); } else { headerEl.after(panel); } } catch (e) { console.error('FightIntel: Error building panel', e); console.error('FightIntel: If error persists, clear browser data to rebuild database'); } } // ============================================================================ // INJECTION FUNCTIONS // ============================================================================ function injectProfile() { const h = document.querySelector('h4'); if (!h || h.dataset.ff) return; h.dataset.ff = '1'; const match = location.href.match(/[?&](?:XID|user2ID)=(\d+)/); const userId = match?.[1]; if (!userId) return; ApiManager.get(`/user/${userId}?selections=personalstats,basic,profile`, async (o) => { // Cache the RSI data immediately await RSICacheManager.set(userId, o, REFRESH_PRIORITY.PROFILE_VIEW); const rsi = calcRSI(USER_BP, o.personalstats, o.basic, o.life); const pct = parseFloat(rsi.adjusted.toFixed(2)); let cls = 'low', note = 'Advantage'; if (pct < settings.lowHigh) { cls = 'high'; note = 'High risk'; } else if (pct < settings.highMed) { cls = 'med'; note = 'Moderate risk'; } const lifePct = o.life?.maximum ? Math.round((o.life.current / o.life.maximum) * 100) : null; const lastAction = o.last_action || o.profile?.last_action || o.basic?.last_action; let rel = null; if (lastAction?.relative) { const mm = lastAction.relative.match(/^(\d+)\s+(\w+)/); if (mm) { const num = mm[1]; const word = mm[2].toLowerCase(); let suffix = 'm'; if (word.startsWith('hour')) suffix = 'h'; else if (word.startsWith('day')) suffix = 'd'; rel = num + suffix; } } let extra = ''; if (lifePct !== null && rel !== null) extra = ` (L ${lifePct}% · A ${rel})`; else if (lifePct !== null) extra = ` (L ${lifePct}%)`; else if (rel !== null) extra = ` (A ${rel})`; const sp = document.createElement('span'); sp.className = `ff-score-badge ${cls}${rsi.woundPenalty > 0 ? ' wounded' : ''}`; sp.innerHTML = ` RSI ${pct}% — ${escapeHtml(note)}${escapeHtml(extra)} ${rsi.woundPenalty > 0 ? '✚' : ''} ${USER_DRUG_DEBUFF > 0 ? `` : ''} `; h.appendChild(sp); // Store prediction for fight capture FightCapture.storePendingAttack({ opponentId: parseInt(userId, 10), opponentName: o.name || o.basic?.name || null, rsiRaw: rsi.raw, rsiAdjusted: rsi.adjusted, riskClass: cls, oppLife: rsi.lifeRatio, oppLevel: o.level || o.basic?.level || null, oppSnapshot: { elo: o.personalstats?.elo, attacksWon: o.personalstats?.attackswon, attacksLost: o.personalstats?.attackslost, xantaken: o.personalstats?.xantaken, networth: o.personalstats?.networth } }); window.__FF_LAST_PROFILE_OBJ = o; buildRSIPlusPanel(h, ME_STATS, o); // Build Fight Intelligence panel buildFightIntelPanel(h, parseInt(userId, 10), o.name || o.basic?.name, pct); // Cache status data if present if (o.status || o.travel) { StatusCacheManager.set(userId, o); } }); } async function injectListItem(honorWrap) { if (honorWrap.dataset.ff) return; honorWrap.dataset.ff = '1'; const profileLink = honorWrap.querySelector('a[href*="profiles.php?XID="]'); if (!profileLink) return; const honorTextWrap = honorWrap.querySelector('.honor-text-wrap') || honorWrap; if (getComputedStyle(honorTextWrap).position === 'static') { honorTextWrap.style.position = 'relative'; } const userIdMatch = profileLink.href.match(/XID=(\d+)/); if (!userIdMatch) return; const userId = userIdMatch[1]; // Add user ID as data attribute for later status updates honorTextWrap.dataset.userId = userId; // Step 1: Check status cache first (60s TTL) console.log(`[User ${userId}] Checking status cache...`); const cachedStatus = await StatusCacheManager.get(userId); if (cachedStatus) { console.log(`[User ${userId}] ✓ Status cache HIT - status:`, cachedStatus.status?.state, 'travel:', cachedStatus.travel?.destination); addStatusIcon(cachedStatus, honorTextWrap, userId); } else { console.log(`[User ${userId}] ✗ Status cache MISS (probably Okay status)`); } // Step 2: Check RSI cache (24h TTL) console.log(`[User ${userId}] Checking RSI cache...`); const cachedRSI = await RSICacheManager.get(userId); if (cachedRSI) { console.log(`[User ${userId}] ✓ RSI cache HIT`); // Use cached RSI data to display triangle const rsi = calcRSI(USER_BP, cachedRSI.personalstats, cachedRSI.basic, cachedRSI.life); const pct = parseFloat(rsi.adjusted.toFixed(2)); const cls = rsi.adjusted < settings.lowHigh ? 'high' : rsi.adjusted < settings.highMed ? 'med' : 'low'; const pos = (100 - Math.min(rsi.adjusted, 200) / 200 * 100) + '%'; const lifePct = cachedRSI.life?.maximum ? Math.round((cachedRSI.life.current / cachedRSI.life.maximum) * 100) : null; const lastAction = cachedRSI.last_action || cachedRSI.profile?.last_action || cachedRSI.basic?.last_action; let tooltipHtml = `RSI: ${escapeHtml(pct.toFixed(2))}%`; if (lifePct !== null) tooltipHtml += `
Life: ${escapeHtml(String(lifePct))}%`; if (lastAction?.relative) tooltipHtml += `
Last action: ${escapeHtml(lastAction.relative)}`; const img = document.createElement('img'); img.className = 'ff-list-arrow-img'; img.style.left = pos; img.src = cls === 'low' ? GREEN_ARROW_UP : cls === 'med' ? YELLOW_ARROW_UP : RED_ARROW_UP; img.setAttribute('width', '20'); img.setAttribute('height', '20'); if (rsi.woundPenalty > 0) img.classList.add('wounded'); img.addEventListener('mouseenter', e => { Tooltip.show(e.pageX, e.pageY, tooltipHtml); ApiManager.get(`/user/${userId}?selections=personalstats,basic,profile`, async d2 => { await RSICacheManager.set(userId, d2, REFRESH_PRIORITY.FACTION_LIST); if (d2.status || d2.travel) { await StatusCacheManager.set(userId, d2); } honorTextWrap.querySelectorAll('.ff-list-arrow-img').forEach(el => el.remove()); injectTriangle(honorTextWrap, d2, userId, e); }); }); img.addEventListener('mousemove', e => Tooltip.show(e.pageX, e.pageY, tooltipHtml)); img.addEventListener('mouseleave', () => Tooltip.hide()); img.addEventListener('click', e => { e.preventDefault(); Tooltip.show(e.pageX, e.pageY, tooltipHtml); setTimeout(Tooltip.hide, 2000); }); honorTextWrap.appendChild(img); // Step 3: Check if status needs refresh using LastCheckCache // This tracks ALL users (even "Okay" status) to prevent unnecessary refreshes const timeSinceCheck = LastCheckCacheManager.getTimeSinceCheck(userId); if (LastCheckCacheManager.shouldCheck(userId)) { console.log(`[User ${userId}] Last checked ${Math.round(timeSinceCheck/1000)}s ago - refreshing status`); // Make lightweight status-only API call ApiManager.get(`/user/${userId}?selections=profile`, async d => { // Mark as checked regardless of result LastCheckCacheManager.set(userId); // Update status cache if user has active status if (d.status || d.travel) { console.log(`[User ${userId}] Status refresh: has status - caching`); await StatusCacheManager.set(userId, d); addStatusIcon(d, honorTextWrap, userId); } else { console.log(`[User ${userId}] Status refresh: no active status`); // Don't cache "Okay" status, but we marked it as checked } }); } else { console.log(`[User ${userId}] Last checked ${Math.round(timeSinceCheck/1000)}s ago - no refresh needed`); } return; // Done - using cached RSI, status refreshed if needed } // Step 4: RSI cache MISS - need to fetch full data console.log(`[User ${userId}] ✗ RSI cache MISS - fetching full data`); ApiManager.get(`/user/${userId}?selections=personalstats,basic,profile`, async d => { // Clear existing RSI indicators only (preserve status icons) honorTextWrap.querySelectorAll('.ff-list-arrow-img').forEach(el => el.remove()); // Cache the RSI data immediately to prevent duplicate API calls await RSICacheManager.set(userId, d, REFRESH_PRIORITY.FACTION_LIST); // Mark as checked in LastCheckCache LastCheckCacheManager.set(userId); const rsi = calcRSI(USER_BP, d.personalstats, d.basic, d.life); const pct = parseFloat(rsi.adjusted.toFixed(2)); const cls = rsi.adjusted < settings.lowHigh ? 'high' : rsi.adjusted < settings.highMed ? 'med' : 'low'; const pos = (100 - Math.min(rsi.adjusted, 200) / 200 * 100) + '%'; const lifePct = d.life?.maximum ? Math.round((d.life.current / d.life.maximum) * 100) : null; const lastAction = d.last_action || d.profile?.last_action || d.basic?.last_action; // Build sanitized tooltip HTML let tooltipHtml = `RSI: ${escapeHtml(pct.toFixed(2))}%`; if (lifePct !== null) tooltipHtml += `
Life: ${escapeHtml(String(lifePct))}%`; if (lastAction?.relative) tooltipHtml += `
Last action: ${escapeHtml(lastAction.relative)}`; // Create triangle indicator const img = document.createElement('img'); img.className = 'ff-list-arrow-img'; img.style.left = pos; img.src = cls === 'low' ? GREEN_ARROW_UP : cls === 'med' ? YELLOW_ARROW_UP : RED_ARROW_UP; img.setAttribute('width', '20'); img.setAttribute('height', '20'); if (rsi.woundPenalty > 0) img.classList.add('wounded'); img.addEventListener('mouseenter', e => { Tooltip.show(e.pageX, e.pageY, tooltipHtml); // Refresh data on hover ApiManager.get(`/user/${userId}?selections=personalstats,basic,profile`, async d2 => { await RSICacheManager.set(userId, d2, REFRESH_PRIORITY.FACTION_LIST); if (d2.status || d2.travel) { await StatusCacheManager.set(userId, d2); } honorTextWrap.querySelectorAll('.ff-list-arrow-img').forEach(el => el.remove()); injectTriangle(honorTextWrap, d2, userId, e); }); }); img.addEventListener('mousemove', e => Tooltip.show(e.pageX, e.pageY, tooltipHtml)); img.addEventListener('mouseleave', () => Tooltip.hide()); img.addEventListener('click', e => { e.preventDefault(); Tooltip.show(e.pageX, e.pageY, tooltipHtml); setTimeout(Tooltip.hide, 2000); }); honorTextWrap.appendChild(img); // Cache status data if present if (d.status || d.travel) { console.log(`[User ${userId}] API returned status data - caching and displaying icons`); await StatusCacheManager.set(userId, d); addStatusIcon(d, honorTextWrap, userId); } else { console.log(`[User ${userId}] API has no status data - preserving cached icons`); } }); } function injectTriangle(honorTextWrap, d, userId, event) { const rsi = calcRSI(USER_BP, d.personalstats, d.basic, d.life); const pct = parseFloat(rsi.adjusted.toFixed(2)); const cls = rsi.adjusted < settings.lowHigh ? 'high' : rsi.adjusted < settings.highMed ? 'med' : 'low'; const pos = (100 - Math.min(rsi.adjusted, 200) / 200 * 100) + '%'; const lifePct = d.life?.maximum ? Math.round((d.life.current / d.life.maximum) * 100) : null; const lastAction = d.last_action || d.profile?.last_action || d.basic?.last_action; let tooltipHtml = `RSI: ${escapeHtml(pct.toFixed(2))}%`; if (lifePct !== null) tooltipHtml += `
Life: ${escapeHtml(String(lifePct))}%`; if (lastAction?.relative) tooltipHtml += `
Last action: ${escapeHtml(lastAction.relative)}`; const img = document.createElement('img'); img.className = 'ff-list-arrow-img'; img.style.left = pos; img.src = cls === 'low' ? GREEN_ARROW_UP : cls === 'med' ? YELLOW_ARROW_UP : RED_ARROW_UP; img.setAttribute('width', '20'); img.setAttribute('height', '20'); if (rsi.woundPenalty > 0) img.classList.add('wounded'); img.addEventListener('mouseenter', e => Tooltip.show(e.pageX, e.pageY, tooltipHtml)); img.addEventListener('mousemove', e => Tooltip.show(e.pageX, e.pageY, tooltipHtml)); img.addEventListener('mouseleave', () => Tooltip.hide()); img.addEventListener('click', e => { e.preventDefault(); Tooltip.show(e.pageX, e.pageY, tooltipHtml); setTimeout(Tooltip.hide, 2000); }); honorTextWrap.appendChild(img); if (event?.pageX) Tooltip.show(event.pageX, event.pageY, tooltipHtml); } function addStatusIcon(statusData, honorTextWrap, userId) { console.log(`[addStatusIcon] Called for user ${userId}, has status:`, !!statusData?.status, 'state:', statusData?.status?.state); if (!statusData?.status) { console.log(`[addStatusIcon] No status data - returning without clearing icons`); return; } console.log(`[addStatusIcon] Clearing existing status icons for user ${userId}`); honorTextWrap.querySelectorAll('.ff-travel-icon, .ff-hospital-icon').forEach(el => { console.log(`[addStatusIcon] Removing icon: ${el.className}`); el.remove(); }); const makeTooltip = s => { let tip = escapeHtml(s.status.description || s.status.state); if (s.status.details) tip += `
${escapeHtml(s.status.details)}`; return tip; }; // Hospital takes priority if (statusData.status.state === "Hospital") { const desc = (statusData.status.description || statusData.status.details || "").trim(); const isAbroad = /^In a\s+.+\s+hospital\s+for\s+\d+\s+min/i.test(desc); const icon = document.createElement('img'); icon.src = HOSPITAL_ICON_URL; icon.className = 'ff-hospital-icon'; icon.alt = 'Hospital'; icon.draggable = false; if (desc) icon.dataset.ffDesc = desc; if (statusData.status.until) icon.dataset.ffUntil = String(statusData.status.until); icon.style.filter = isAbroad ? 'grayscale(0) brightness(1) drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 2px #ffb300)' : 'grayscale(0) brightness(1) drop-shadow(0 0 3px #2094fa) drop-shadow(0 0 2px #42a5f5)'; let lastTooltip = makeTooltip(statusData); icon.addEventListener('mouseenter', ev => { Tooltip.show(ev.pageX, ev.pageY, lastTooltip); ApiManager.get(`/user/${userId}?selections=basic`, newStatus => { if (!newStatus.status) return; if (JSON.stringify(newStatus.status) !== JSON.stringify(statusData.status)) { honorTextWrap.querySelectorAll('.ff-hospital-icon, .ff-travel-icon').forEach(el => el.remove()); addStatusIcon(newStatus, honorTextWrap, userId); Tooltip.show(ev.pageX, ev.pageY, makeTooltip(newStatus)); } }); }); icon.addEventListener('mousemove', ev => Tooltip.show(ev.pageX, ev.pageY, lastTooltip)); icon.addEventListener('mouseleave', () => Tooltip.hide()); icon.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); Tooltip.show(ev.pageX, ev.pageY, lastTooltip); setTimeout(Tooltip.hide, 2000); }); honorTextWrap.appendChild(icon); // Add countdown chip addCountdownChip(icon, honorTextWrap); return; } // Travel/Abroad if (statusData.status.state === "Traveling" || statusData.status.state === "Abroad") { const icon = document.createElement('img'); icon.src = PLANE_ICON_URL; icon.className = 'ff-travel-icon ' + (statusData.status.state === "Traveling" ? 'ff-traveling' : 'ff-abroad'); icon.alt = statusData.status.state; icon.draggable = false; const desc = (statusData.status.description || statusData.status.details || '').trim(); if (desc) icon.dataset.ffDesc = desc; let lastTooltip = makeTooltip(statusData); icon.addEventListener('mouseenter', ev => { Tooltip.show(ev.pageX, ev.pageY, lastTooltip); ApiManager.get(`/user/${userId}?selections=basic`, newStatus => { if (!newStatus.status) return; if (JSON.stringify(newStatus.status) !== JSON.stringify(statusData.status)) { honorTextWrap.querySelectorAll('.ff-travel-icon, .ff-hospital-icon').forEach(el => el.remove()); addStatusIcon(newStatus, honorTextWrap, userId); Tooltip.show(ev.pageX, ev.pageY, makeTooltip(newStatus)); } }); }); icon.addEventListener('mousemove', ev => Tooltip.show(ev.pageX, ev.pageY, lastTooltip)); icon.addEventListener('mouseleave', () => Tooltip.hide()); icon.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); Tooltip.show(ev.pageX, ev.pageY, lastTooltip); setTimeout(Tooltip.hide, 2000); }); honorTextWrap.appendChild(icon); // Add location badge addLocationBadge(icon, honorTextWrap); } } function addCountdownChip(icon, parent) { if (icon.dataset.ffChipped === '1') return; let untilMs = null; const untilAttr = icon.dataset.ffUntil; if (untilAttr) { const raw = parseInt(untilAttr, 10); if (!isNaN(raw) && raw > 0) { untilMs = raw < 1e12 ? raw * 1000 : raw; } } if (!untilMs) { const desc = icon.dataset.ffDesc || ''; const mins = parseMinutesFromText(desc); if (mins != null) { untilMs = Date.now() + mins * 60 * 1000; } } if (!untilMs) return; let chip = parent.querySelector('.ff-countdown-chip'); if (!chip) { chip = document.createElement('span'); chip.className = 'ff-countdown-chip'; parent.appendChild(chip); } chip.dataset.ffUntil = String(untilMs); icon.dataset.ffChipped = '1'; } function addLocationBadge(icon, parent) { if (parent.querySelector('.ff-loc-badge')) return; const desc = icon.dataset.ffDesc || ''; const info = parseTravelInfo(desc); if (!info) return; if (getComputedStyle(parent).position === 'static') { parent.style.position = 'relative'; } const badge = document.createElement('span'); badge.className = 'ff-loc-badge'; badge.dataset.kind = info.kind; const arrow = info.kind === 'travel' ? '→ ' : info.kind === 'return' ? '← ' : ''; badge.textContent = arrow + abbreviateLocation(info.loc); parent.appendChild(badge); } // ============================================================================ // BOUNTY PAGE INJECTION // ============================================================================ function injectBountyItem(listItem) { if (listItem.getAttribute('data-ff-bounty') === '1') return; listItem.setAttribute('data-ff-bounty', '1'); // Find the profile link (target name) const profileLink = listItem.querySelector('a[href*="profiles.php?XID="]'); if (!profileLink) return; const userIdMatch = profileLink.href.match(/XID=(\d+)/); if (!userIdMatch) return; const userId = userIdMatch[1]; // Find the cell/container that holds the name - this becomes our positioning parent // On bounty page, the structure is: li > div.target_left > a let container = profileLink.parentElement; // Make container a positioning context (like honor-text-wrap on faction pages) container.style.position = 'relative'; container.style.overflow = 'visible'; ApiManager.get(`/user/${userId}?selections=personalstats,basic,profile`, async d => { if (!d) return; // Cache the RSI data immediately await RSICacheManager.set(userId, d, REFRESH_PRIORITY.BOUNTY_LIST); // Clear existing indicators from this container container.querySelectorAll('.ff-list-arrow-img, .ff-travel-icon, .ff-hospital-icon, .ff-loc-badge, .ff-countdown-chip') .forEach(el => el.remove()); const rsi = calcRSI(USER_BP, d.personalstats, d.basic, d.life); const pct = parseFloat(rsi.adjusted.toFixed(2)); const cls = rsi.adjusted < settings.lowHigh ? 'high' : rsi.adjusted < settings.highMed ? 'med' : 'low'; // Position on 0-200% scale (same as faction pages) const pos = (100 - Math.min(rsi.adjusted, 200) / 200 * 100) + '%'; const lifePct = d.life?.maximum ? Math.round((d.life.current / d.life.maximum) * 100) : null; const lastAction = d.last_action || d.profile?.last_action || d.basic?.last_action; // Build tooltip let tooltipHtml = `RSI: ${escapeHtml(pct.toFixed(2))}%`; if (lifePct !== null) tooltipHtml += `
Life: ${escapeHtml(String(lifePct))}%`; if (d.status?.description) tooltipHtml += `
Status: ${escapeHtml(d.status.description)}`; if (lastAction?.relative) tooltipHtml += `
Last action: ${escapeHtml(lastAction.relative)}`; // Create triangle indicator using same class as faction pages (.ff-list-arrow-img) const img = document.createElement('img'); img.className = 'ff-list-arrow-img'; img.style.left = pos; img.src = cls === 'low' ? GREEN_ARROW_UP : cls === 'med' ? YELLOW_ARROW_UP : RED_ARROW_UP; img.setAttribute('width', '20'); img.setAttribute('height', '20'); if (rsi.woundPenalty > 0) img.classList.add('wounded'); img.addEventListener('mouseenter', e => Tooltip.show(e.pageX, e.pageY, tooltipHtml)); img.addEventListener('mousemove', e => Tooltip.show(e.pageX, e.pageY, tooltipHtml)); img.addEventListener('mouseleave', () => Tooltip.hide()); img.addEventListener('click', e => { e.preventDefault(); Tooltip.show(e.pageX, e.pageY, tooltipHtml); setTimeout(Tooltip.hide, 2000); }); container.appendChild(img); // Cache status data if present if (d.status || d.travel) { StatusCacheManager.set(userId, d); } // Add status icons using the SAME function as faction pages addStatusIcon(d, container, userId); }); } // ============================================================================ // MARKET ATTACK BUTTONS // ============================================================================ function injectMarketAttackButton(profileLink) { if (profileLink.parentElement?.querySelector('.ff-attack-btn')) return; const xidMatch = profileLink.getAttribute('href')?.match(/XID=(\d+)/); if (!xidMatch) return; const xid = xidMatch[1]; const btn = document.createElement('a'); btn.href = `https://www.torn.com/loader.php?sid=attack&user2ID=${xid}`; btn.target = '_blank'; btn.className = 'ff-attack-btn'; btn.title = 'Attack this player'; btn.style.cssText = 'margin-left:5px;background:transparent;border:none;padding:0;cursor:pointer;vertical-align:middle;display:inline-block;'; const icon = document.createElement('img'); icon.src = CIRCLE_ICON_URL; icon.alt = 'Attack'; icon.style.cssText = 'width:16px;height:16px;display:inline-block;filter:drop-shadow(0 0 5px #ff1744) drop-shadow(0 0 6px #c62828);transition:filter 0.2s;'; btn.appendChild(icon); btn.addEventListener('mouseenter', () => { icon.style.filter = 'drop-shadow(0 0 8px #ff1744) drop-shadow(0 0 14px #c62828)'; }); btn.addEventListener('mouseleave', () => { icon.style.filter = 'drop-shadow(0 0 5px #ff1744) drop-shadow(0 0 6px #c62828)'; }); profileLink.parentElement?.appendChild(btn); } // ============================================================================ // BOUNTY SNIPER MODULE // With cross-tab sync, draggable panel, and mobile optimization // ============================================================================ const BountySniper = (() => { // State let isEnabled = false; let targets = []; // Current sniper targets (shown in panel) let rsiCache = new Map(); // userId -> {rsi, timestamp} let beatenCache = new Map(); // userId -> {wins, losses} let apiCallsThisMinute = 0; let lastMinuteReset = Date.now(); let scanInterval = null; let heartbeatInterval = null; let panelEl = null; let isCollapsed = false; // Progressive scanning state let cachedBounties = []; // Full bounty list from page scraping let cachedCandidates = []; // Filtered candidates awaiting API check let lastBountyFetch = 0; // Timestamp of last bounty page fetch let scanIndex = 0; // Current position in candidate list let verifiedTargets = new Map(); // userId -> bounty object (API-verified good targets) // Target verification let targetLastVerified = new Map(); // userId -> timestamp // Cross-tab sync state const TAB_ID = Math.random().toString(36).substr(2, 9); let isLeader = false; let isProvisionalFollower = true; // New pages start as provisional followers const LEADER_TIMEOUT = 5000; // 5 seconds without heartbeat = leader lost const HEARTBEAT_INTERVAL = 2000; // Send heartbeat every 2 seconds const VISIBILITY_TAKEOVER_TIME = 10000; // Take leadership after 10 seconds of viewing (PC) const LEADERSHIP_CLAIM_DELAY = 15000; // Wait 15 seconds before claiming leadership (mobile-friendly) const STORAGE_KEY_TARGETS = 'ff_sniper_targets'; const STORAGE_KEY_LEADER = 'ff_sniper_leader'; const STORAGE_KEY_HEARTBEAT = 'ff_sniper_heartbeat'; const STORAGE_KEY_POSITION = 'ff_sniper_position'; const STORAGE_KEY_COLLAPSED = 'ff_sniper_collapsed'; const STORAGE_KEY_SCAN_STATE = 'ff_sniper_scan_state'; const STORAGE_KEY_FORCE_LEADER = 'ff_sniper_force_leader'; // For forced takeover const STORAGE_KEY_FULL_STATE = 'ff_sniper_full_state'; // Comprehensive state for mobile const STORAGE_KEY_NAVIGATING = 'ff_sniper_navigating'; // Navigation detection // Mobile/PDA detection const isMobilePDA = /PDA|Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) || window.innerWidth < 768; // Visibility tracking let visibilityStartTime = 0; let visibilityCheckInterval = null; let panelCreated = false; // Prevent duplicate panels let leadershipClaimTimer = null; // Delayed leadership claim let receivedUpdatesDuringDelay = false; // Track if we got updates while waiting // Watchdog / reconnection handling let watchdogInterval = null; let lastActivityTime = Date.now(); const WATCHDOG_INTERVAL = 30000; // Check every 30 seconds const WATCHDOG_TIMEOUT = 120000; // Restart if no activity for 2 minutes // Drag state let isDragging = false; let dragStartX = 0; let dragStartY = 0; let panelStartX = 0; let panelStartY = 0; const RSI_CACHE_TTL = 10 * 60 * 1000; // 10 minutes for general cache const TARGET_VERIFY_TTL = 45 * 1000; // 45 seconds - verify panel targets frequently const BOUNTY_REFRESH_INTERVAL = 90 * 1000; // Refresh bounty pages every 90 seconds const MAX_TARGETS = 5; // Throttling constants - slower to reduce load const PAGE_LOAD_DELAY = 2500; // 2.5 seconds between page loads (was 1.5s) const API_CALL_DELAY = 200; // 200ms between API calls (was 100ms) // Check if a page-scraped status matches user's enabled preferences function statusMatchesPrefs(status) { if (status === 'Okay' && settings.sniperShowOkay) return true; if (status === 'Hospital' && settings.sniperShowHospital) return true; if (status === 'Traveling' && settings.sniperShowTraveling) return true; return false; } // ========================================================================= // CROSS-TAB SYNC // ========================================================================= // Try to become leader function tryBecomeLeader() { const now = Date.now(); const lastHeartbeat = parseInt(localStorage.getItem(STORAGE_KEY_HEARTBEAT) || '0', 10); const currentLeader = localStorage.getItem(STORAGE_KEY_LEADER); // Become leader if: no leader, leader timed out, or we are already leader if (!currentLeader || currentLeader === TAB_ID || (now - lastHeartbeat) > LEADER_TIMEOUT) { localStorage.setItem(STORAGE_KEY_LEADER, TAB_ID); localStorage.setItem(STORAGE_KEY_HEARTBEAT, now.toString()); if (!isLeader) { isLeader = true; console.log(`BountySniper: Tab ${TAB_ID} became LEADER`); restoreScanState(); updatePanelRole(); // Start scanning if we're not in provisional mode and don't have scan interval if (!isProvisionalFollower && !scanInterval) { console.log('BountySniper: Starting scanning after leadership takeover'); startLeaderScanning(); } } return true; } if (isLeader) { isLeader = false; updatePanelRole(); } return false; } // Send leader heartbeat function sendHeartbeat() { if (isLeader) { localStorage.setItem(STORAGE_KEY_HEARTBEAT, Date.now().toString()); } } // Check if current leader is still alive function checkLeader() { const now = Date.now(); const lastHeartbeat = parseInt(localStorage.getItem(STORAGE_KEY_HEARTBEAT) || '0', 10); const currentLeader = localStorage.getItem(STORAGE_KEY_LEADER); if (currentLeader === TAB_ID) { // We are leader, send heartbeat sendHeartbeat(); return; } // Check if leader timed out if ((now - lastHeartbeat) > LEADER_TIMEOUT) { console.log('BountySniper: Leader timed out, trying to take over...'); tryBecomeLeader(); } } // Broadcast targets to other tabs function broadcastTargets() { if (!isLeader) return; const data = { targets: targets, timestamp: Date.now(), scanIndex: scanIndex, cachedCandidatesCount: cachedCandidates.length, progress: cachedCandidates.length > 0 ? Math.round((getCheckedCount() / cachedCandidates.length) * 100) : 0 }; localStorage.setItem(STORAGE_KEY_TARGETS, JSON.stringify(data)); } // Broadcast scan state for other tabs to continue if they become leader function broadcastScanState() { if (!isLeader) return; const state = { scanIndex, lastBountyFetch, verifiedTargets: Array.from(verifiedTargets.entries()), rsiCache: Array.from(rsiCache.entries()).slice(0, 100) }; try { localStorage.setItem(STORAGE_KEY_SCAN_STATE, JSON.stringify(state)); } catch (e) { // localStorage full console.log('BountySniper: localStorage full, clearing old scan state'); } // Also save full state for mobile navigation saveFullState(); } // Save comprehensive state (for mobile navigation recovery) function saveFullState() { try { const fullState = { timestamp: Date.now(), targets: targets, scanIndex: scanIndex, lastBountyFetch: lastBountyFetch, verifiedTargets: Array.from(verifiedTargets.entries()), cachedCandidates: cachedCandidates.slice(0, 200), // Limit size cachedBounties: cachedBounties.slice(0, 200), rsiCache: Array.from(rsiCache.entries()).slice(0, 100), progress: cachedCandidates.length > 0 ? Math.round((getCheckedCount() / cachedCandidates.length) * 100) : 0 }; localStorage.setItem(STORAGE_KEY_FULL_STATE, JSON.stringify(fullState)); } catch (e) { console.log('BountySniper: Could not save full state', e); } } // Cleanup stale localStorage data (older than threshold) function cleanupStaleData(threshold) { try { let cleaned = []; // Check targets timestamp const targetsData = JSON.parse(localStorage.getItem(STORAGE_KEY_TARGETS) || '{}'); if (targetsData.timestamp && (Date.now() - targetsData.timestamp) > threshold) { localStorage.removeItem(STORAGE_KEY_TARGETS); cleaned.push('targets'); } // Check full state timestamp const fullState = JSON.parse(localStorage.getItem(STORAGE_KEY_FULL_STATE) || '{}'); if (fullState.timestamp && (Date.now() - fullState.timestamp) > threshold) { localStorage.removeItem(STORAGE_KEY_FULL_STATE); cleaned.push('fullState'); } // Check scan state (no timestamp, but clear if leader is stale) const lastHeartbeat = parseInt(localStorage.getItem(STORAGE_KEY_HEARTBEAT) || '0', 10); if ((Date.now() - lastHeartbeat) > threshold) { localStorage.removeItem(STORAGE_KEY_SCAN_STATE); localStorage.removeItem(STORAGE_KEY_LEADER); localStorage.removeItem(STORAGE_KEY_HEARTBEAT); cleaned.push('scanState', 'leader', 'heartbeat'); } if (cleaned.length > 0) { console.log(`BountySniper: Cleaned stale data: ${cleaned.join(', ')}`); } return cleaned.length > 0; } catch (e) { console.log('BountySniper: Error cleaning stale data', e); return false; } } // Load comprehensive state (for mobile navigation recovery) function loadFullState() { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY_FULL_STATE) || '{}'); // Check if state is fresh (less than 5 minutes old) const stateAge = Date.now() - (data.timestamp || 0); const isFresh = stateAge < 5 * 60 * 1000; // 5 minutes if (!isFresh) { console.log(`BountySniper: Saved state is stale (${(stateAge/1000/60).toFixed(1)} mins old), starting fresh`); return false; } // Restore targets (for immediate display) if (data.targets && data.targets.length > 0) { targets = data.targets; console.log(`BountySniper: Restored ${targets.length} targets from saved state`); } // Restore scan progress if (data.scanIndex !== undefined) { scanIndex = data.scanIndex; } if (data.lastBountyFetch) { lastBountyFetch = data.lastBountyFetch; } if (data.verifiedTargets) { verifiedTargets = new Map(data.verifiedTargets); } if (data.cachedCandidates && data.cachedCandidates.length > 0) { cachedCandidates = data.cachedCandidates; } if (data.cachedBounties && data.cachedBounties.length > 0) { cachedBounties = data.cachedBounties; } if (data.rsiCache) { rsiCache = new Map(data.rsiCache); } console.log(`BountySniper: Loaded full state - ${targets.length} targets, ${cachedCandidates.length} candidates, progress: ${data.progress || 0}%`); return true; } catch (e) { console.log('BountySniper: Could not load full state', e); return false; } } // Mark navigation start (helps new page know we're mid-navigation) function markNavigating() { try { localStorage.setItem(STORAGE_KEY_NAVIGATING, Date.now().toString()); } catch (e) {} } // Check if we recently navigated (within last 10 seconds) function isRecentNavigation() { try { const navTime = parseInt(localStorage.getItem(STORAGE_KEY_NAVIGATING) || '0', 10); return (Date.now() - navTime) < 10000; } catch (e) { return false; } } // Receive targets from leader tab // isStorageEvent = true means this was triggered by another tab, false = initial read function receiveTargets(isStorageEvent = false) { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY_TARGETS) || '{}'); if (data.targets && Array.isArray(data.targets)) { targets = data.targets; // Only mark as "received updates" if this is an actual storage event from another tab // AND the data is fresh (less than 30 seconds old) if (isStorageEvent && data.timestamp && (Date.now() - data.timestamp) < 30000) { receivedUpdatesDuringDelay = true; console.log('BountySniper: Received LIVE update from leader'); } renderPanel(); updateStatusFromSync(data); } } catch (e) { // Invalid data } } // Update status display from synced data function updateStatusFromSync(data) { if (!panelEl) return; const status = panelEl.querySelector('.ff-sniper-status'); if (status && data.progress !== undefined) { status.innerHTML = `● Synced | ${data.progress}% scanned`; } } // Restore scan state when becoming leader function restoreScanState() { try { const state = JSON.parse(localStorage.getItem(STORAGE_KEY_SCAN_STATE) || '{}'); if (state.scanIndex !== undefined) { scanIndex = state.scanIndex; } if (state.lastBountyFetch) { lastBountyFetch = state.lastBountyFetch; } if (state.verifiedTargets) { verifiedTargets = new Map(state.verifiedTargets); } if (state.rsiCache) { rsiCache = new Map(state.rsiCache); } console.log(`BountySniper: Restored scan state - index: ${scanIndex}, verified: ${verifiedTargets.size}`); } catch (e) { console.log('BountySniper: Could not restore scan state'); } } // Listen for storage changes (other tabs updating) function setupStorageListener() { window.addEventListener('storage', (e) => { if (e.key === STORAGE_KEY_TARGETS && !isLeader) { receiveTargets(true); // true = this is a real storage event from another tab } if (e.key === STORAGE_KEY_LEADER) { // Leader changed const newLeader = e.newValue; if (newLeader !== TAB_ID && isLeader) { // We lost leadership isLeader = false; if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } updatePanelRole(); console.log(`BountySniper: Tab ${TAB_ID} is now FOLLOWER (another tab took over)`); } } if (e.key === STORAGE_KEY_FORCE_LEADER) { // Another tab is forcing leadership try { const data = JSON.parse(e.newValue || '{}'); if (data.tabId && data.tabId !== TAB_ID && isLeader) { // Someone else is forcing takeover, relinquish leadership isLeader = false; if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } updatePanelRole(); console.log(`BountySniper: Tab ${TAB_ID} relinquished leadership to ${data.tabId}`); } } catch (err) {} } if (e.key === STORAGE_KEY_POSITION) { // Position changed from another tab, apply it try { const pos = JSON.parse(e.newValue || '{}'); if (pos.x !== undefined && pos.y !== undefined) { applyPosition(pos.x, pos.y); } } catch (err) {} } if (e.key === STORAGE_KEY_COLLAPSED) { // Collapsed state changed try { const collapsed = JSON.parse(e.newValue || 'false'); if (isCollapsed !== collapsed) { isCollapsed = collapsed; if (panelEl) { panelEl.classList.toggle('collapsed', isCollapsed); panelEl.querySelector('.ff-sniper-toggle').textContent = isCollapsed ? '▶' : '▼'; } } } catch (err) {} } }); } // Update panel to show if leader or follower function updatePanelRole() { if (!panelEl) return; panelEl.classList.toggle('follower', !isLeader); } // Force this tab to become leader (called by user clicking satellite icon) function forceLeadership() { console.log(`BountySniper: Tab ${TAB_ID} forcing leadership takeover`); // No longer provisional isProvisionalFollower = false; // Broadcast force takeover message localStorage.setItem(STORAGE_KEY_FORCE_LEADER, JSON.stringify({ tabId: TAB_ID, timestamp: Date.now() })); // Take leadership localStorage.setItem(STORAGE_KEY_LEADER, TAB_ID); localStorage.setItem(STORAGE_KEY_HEARTBEAT, Date.now().toString()); if (!isLeader) { isLeader = true; // Load full state (mobile) or restore scan state (desktop) if (isMobilePDA) { loadFullState(); } else { restoreScanState(); } updatePanelRole(); // Start scanning if not already if (!scanInterval) { startLeaderScanning(); } console.log(`BountySniper: Tab ${TAB_ID} is now LEADER (forced)`); } } // Handle visibility change - take leadership after 10 seconds of viewing function setupVisibilityTracking() { // Track when page becomes visible const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { visibilityStartTime = Date.now(); } else { visibilityStartTime = 0; } }; document.addEventListener('visibilitychange', handleVisibilityChange); // Initial state if (document.visibilityState === 'visible') { visibilityStartTime = Date.now(); } // Use longer takeover time on mobile const takeoverTime = isMobilePDA ? VISIBILITY_TAKEOVER_TIME * 2 : VISIBILITY_TAKEOVER_TIME; // Check periodically if we should take over visibilityCheckInterval = setInterval(() => { // Don't take over if we're still in provisional follower mode if (isProvisionalFollower) { return; } if (!isLeader && visibilityStartTime > 0) { const viewingTime = Date.now() - visibilityStartTime; if (viewingTime >= takeoverTime) { // On mobile, only take over if we haven't received updates recently if (isMobilePDA && receivedUpdatesDuringDelay) { console.log('BountySniper: Skipping visibility takeover - received recent updates'); receivedUpdatesDuringDelay = false; // Reset for next check visibilityStartTime = Date.now(); // Reset timer return; } console.log(`BountySniper: Page visible for ${(viewingTime/1000).toFixed(0)}s, taking leadership`); forceLeadership(); visibilityStartTime = Date.now(); // Reset to prevent repeated takeovers } } }, 2000); } // ========================================================================= // WATCHDOG / RECONNECTION HANDLING // ========================================================================= // Setup watchdog to detect and recover from script crashes function setupWatchdog() { if (watchdogInterval) { clearInterval(watchdogInterval); } lastActivityTime = Date.now(); watchdogInterval = setInterval(() => { const timeSinceActivity = Date.now() - lastActivityTime; // If leader and no activity for too long, something is wrong if (isLeader && timeSinceActivity > WATCHDOG_TIMEOUT) { console.warn(`BountySniper: ⚠️ Watchdog triggered - no activity for ${(timeSinceActivity/1000).toFixed(0)}s, restarting...`); restartSniper(); } // Update panel status to show we're alive if (isEnabled && panelEl) { const status = panelEl.querySelector('.ff-sniper-status'); if (status && !status.innerHTML.includes('Error')) { // Heartbeat indicator - subtle update to show script is running lastActivityTime = Date.now(); // Watchdog check counts as activity } } }, WATCHDOG_INTERVAL); console.log('BountySniper: Watchdog started'); } // Restart the sniper after a crash/timeout function restartSniper() { console.log('BountySniper: Attempting restart...'); // Clear all intervals if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (visibilityCheckInterval) { clearInterval(visibilityCheckInterval); visibilityCheckInterval = null; } // Reset state lastActivityTime = Date.now(); // Try to become leader again tryBecomeLeader(); // Restart scanning if leader if (isLeader) { console.log('BountySniper: Restart successful - resuming as leader'); // Setup heartbeat heartbeatInterval = setInterval(() => { checkLeader(); }, HEARTBEAT_INTERVAL); // Setup visibility tracking setupVisibilityTracking(); // Start scanning const intervalMs = settings.sniperFetchInterval * 1000; scanInterval = setInterval(() => { if (isLeader) { scan(); broadcastScanState(); } }, intervalMs); // Immediate scan scan(); updateStatus('Restarted'); } else { console.log('BountySniper: Restart - became follower'); receiveTargets(); } } // ========================================================================= // DRAG FUNCTIONALITY // ========================================================================= // Get saved position or default function getSavedPosition() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY_POSITION) || '{}'); if (saved.x !== undefined && saved.y !== undefined) { return saved; } } catch (e) {} // Default position - bottom left, accounting for mobile const isMobile = window.innerWidth < 768; return { x: isMobile ? 10 : 80, y: window.innerHeight - (isMobile ? 290 : 240) }; } // Save position to localStorage function savePosition(x, y) { try { localStorage.setItem(STORAGE_KEY_POSITION, JSON.stringify({ x, y })); } catch (e) {} } // Apply position to panel function applyPosition(x, y) { if (!panelEl) return; // Bounds checking const rect = panelEl.getBoundingClientRect(); const maxX = window.innerWidth - rect.width - 10; const maxY = window.innerHeight - rect.height - 10; x = Math.max(10, Math.min(x, maxX)); y = Math.max(10, Math.min(y, maxY)); panelEl.style.left = x + 'px'; panelEl.style.top = y + 'px'; panelEl.style.bottom = 'auto'; panelEl.style.right = 'auto'; } // Handle drag start (mouse) function onMouseDown(e) { // Only drag from header, not toggle button if (e.target.closest('.ff-sniper-toggle')) return; if (!e.target.closest('.ff-sniper-header')) return; e.preventDefault(); startDrag(e.clientX, e.clientY); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); } function onMouseMove(e) { if (!isDragging) return; moveDrag(e.clientX, e.clientY); } function onMouseUp() { endDrag(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } // Handle drag start (touch) function onTouchStart(e) { // Only drag from header, not toggle button if (e.target.closest('.ff-sniper-toggle')) return; if (!e.target.closest('.ff-sniper-header')) return; const touch = e.touches[0]; startDrag(touch.clientX, touch.clientY); } function onTouchMove(e) { if (!isDragging) return; e.preventDefault(); const touch = e.touches[0]; moveDrag(touch.clientX, touch.clientY); } function onTouchEnd() { endDrag(); } // Common drag functions function startDrag(clientX, clientY) { isDragging = true; dragStartX = clientX; dragStartY = clientY; const rect = panelEl.getBoundingClientRect(); panelStartX = rect.left; panelStartY = rect.top; panelEl.classList.add('dragging'); } function moveDrag(clientX, clientY) { const deltaX = clientX - dragStartX; const deltaY = clientY - dragStartY; applyPosition(panelStartX + deltaX, panelStartY + deltaY); } function endDrag() { if (!isDragging) return; isDragging = false; panelEl.classList.remove('dragging'); // Save position const rect = panelEl.getBoundingClientRect(); savePosition(rect.left, rect.top); } // Reset position on double-click function onDoubleClick(e) { if (!e.target.closest('.ff-sniper-header')) return; if (e.target.closest('.ff-sniper-toggle')) return; // Reset to default position localStorage.removeItem(STORAGE_KEY_POSITION); const pos = getSavedPosition(); applyPosition(pos.x, pos.y); savePosition(pos.x, pos.y); } // Setup all drag event listeners function setupDragListeners() { if (!panelEl) return; // Mouse events panelEl.addEventListener('mousedown', onMouseDown); // Touch events panelEl.addEventListener('touchstart', onTouchStart, { passive: true }); panelEl.addEventListener('touchmove', onTouchMove, { passive: false }); panelEl.addEventListener('touchend', onTouchEnd); // Double-click to reset panelEl.addEventListener('dblclick', onDoubleClick); } // Parse bounty data from HTML string function parseBountyHtml(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const bounties = []; // Debug: log what we're working with const allLis = doc.querySelectorAll('li'); console.log('BountySniper: Found', allLis.length, 'list items in HTML'); // Try multiple selectors for bounty rows const selectors = [ 'ul.bounties-list > li', 'ul[class*="bounty"] > li', '.bounties-wrap li', 'li[class*="bounty"]', 'li' // fallback to all li elements ]; let foundItems = []; for (const sel of selectors) { const items = doc.querySelectorAll(sel); if (items.length > 0) { console.log('BountySniper: Selector', sel, 'matched', items.length, 'items'); // Only use this selector if it found items with profile links const withLinks = Array.from(items).filter(li => li.querySelector('a[href*="profiles.php?XID="]')); if (withLinks.length > 0) { foundItems = withLinks; break; } } } console.log('BountySniper: Processing', foundItems.length, 'items with profile links'); foundItems.forEach(li => { const profileLink = li.querySelector('a[href*="profiles.php?XID="]'); if (!profileLink) return; const userIdMatch = profileLink.href.match(/XID=(\d+)/); if (!userIdMatch) return; const userId = userIdMatch[1]; const name = profileLink.textContent.trim(); // Extract level - look for the level column let level = 0; const levelEl = li.querySelector('[class*="level"]') || li.querySelector('div:nth-child(3)'); if (levelEl) { const levelMatch = levelEl.textContent.match(/(\d+)/); if (levelMatch) level = parseInt(levelMatch[1], 10); } // Fallback: search all text for a standalone number that looks like level if (!level) { const allText = li.textContent; const matches = allText.match(/\b([1-9]\d?|100)\b/g); if (matches && matches.length > 0) { // Usually level is a small number, pick first reasonable one for (const m of matches) { const n = parseInt(m, 10); if (n >= 1 && n <= 100) { level = n; break; } } } } // Extract reward let reward = 0; const rewardEl = li.querySelector('[class*="reward"]') || li.querySelector('div:first-child'); if (rewardEl) { const rewardText = rewardEl.textContent.replace(/[,$]/g, ''); const rewardMatch = rewardText.match(/([\d,]+)/); if (rewardMatch) reward = parseInt(rewardMatch[1].replace(/,/g, ''), 10); } // Fallback: search for dollar amounts if (!reward) { const dollarMatch = li.textContent.match(/\$?([\d,]+)/); if (dollarMatch) reward = parseInt(dollarMatch[1].replace(/,/g, ''), 10); } // Extract status - look for status column/link let status = 'Okay'; let hospitalTime = null; // null = unknown, 0 = just released, >0 = time remaining in minutes // Look for status link/text const statusLink = li.querySelector('a[href*="hospitalview"]'); const travelingLink = li.querySelector('a[href*="travelagency"], a[class*="traveling"], a[class*="abroad"]'); const statusEl = li.querySelector('[class*="status"]'); const liText = li.textContent; if (statusLink || /hospital/i.test(liText)) { status = 'Hospital'; // Try to extract hospital time - formats: "HH:MM:SS" or "MM:SS" or "X hrs Y mins" etc. const timePatterns = [ /(\d+):(\d+):(\d+)/, // HH:MM:SS /(\d+):(\d+)/, // MM:SS /(\d+)\s*h(?:r|our)?s?\s*(\d+)?\s*m/i, // "X hrs Y mins" /(\d+)\s*m(?:in)?s?/i // "X mins" ]; for (const pattern of timePatterns) { const match = liText.match(pattern); if (match) { if (match[3] !== undefined) { // HH:MM:SS format hospitalTime = parseInt(match[1], 10) * 60 + parseInt(match[2], 10) + (parseInt(match[3], 10) / 60); } else if (match[2] !== undefined && match[0].includes(':')) { // MM:SS format hospitalTime = parseInt(match[1], 10) + (parseInt(match[2], 10) / 60); } else if (match[2] !== undefined) { // X hrs Y mins format hospitalTime = parseInt(match[1], 10) * 60 + (parseInt(match[2], 10) || 0); } else { // Just minutes hospitalTime = parseInt(match[1], 10); } break; } } // If we couldn't parse time, assume they're in for a while if (hospitalTime === null) hospitalTime = 999; } else if (travelingLink || /travel|abroad|flying/i.test(liText)) { status = 'Traveling'; } bounties.push({ id: userId, userId, name, level, reward, status, hospitalTime, rsi: null, beaten: null, valueScore: 0 }); }); return bounties; } // Parse bounties from current page DOM (when on bounty page) function parseBountyDOM() { const bounties = []; // More comprehensive selectors for bounty rows const rows = document.querySelectorAll('ul.bounties-list > li, [class*="bounties"] li, ul.item > li'); console.log('BountySniper: Found', rows.length, 'potential bounty rows'); rows.forEach(li => { const profileLink = li.querySelector('a[href*="profiles.php?XID="]'); if (!profileLink) return; const userIdMatch = profileLink.href.match(/XID=(\d+)/); if (!userIdMatch) return; const userId = userIdMatch[1]; const name = profileLink.textContent.trim(); const liText = li.textContent; // Get data from table cells const cells = li.querySelectorAll('div'); let level = 0, reward = 0; cells.forEach(cell => { const text = cell.textContent.trim(); // Check for reward (has $ or large number) if (/^\$?[\d,]+$/.test(text.replace(/,/g, ''))) { const num = parseInt(text.replace(/[$,]/g, ''), 10); if (num > 10000) reward = num; else if (num >= 1 && num <= 100 && !level) level = num; } }); // Check status - look for status column specifically let status = 'Okay'; // Default to Okay let hospitalTime = null; // Look for status-related elements const statusLink = li.querySelector('a[href*="hospitalview"]'); const travelLink = li.querySelector('a[class*="traveling"], a[class*="abroad"]'); const okayLink = li.querySelector('a[class*="okay"], span[class*="okay"]'); // Check the STATUS column text const statusTexts = ['Hospital', 'Traveling', 'Abroad', 'Okay']; for (const cell of cells) { const cellText = cell.textContent.trim(); for (const st of statusTexts) { if (cellText.toLowerCase() === st.toLowerCase()) { if (st === 'Hospital') { status = 'Hospital'; } else if (st === 'Traveling' || st === 'Abroad') { status = 'Traveling'; } else if (st === 'Okay') { status = 'Okay'; } break; } } } // Fallback detection if (statusLink) { status = 'Hospital'; } else if (travelLink) { status = 'Traveling'; } // Parse hospital time if applicable if (status === 'Hospital') { const timePatterns = [ /(\d+):(\d+):(\d+)/, // HH:MM:SS /(\d+):(\d+)/ // MM:SS or HH:MM ]; for (const pattern of timePatterns) { const match = liText.match(pattern); if (match) { if (match[3] !== undefined) { hospitalTime = parseInt(match[1], 10) * 60 + parseInt(match[2], 10); } else { const first = parseInt(match[1], 10); const second = parseInt(match[2], 10); hospitalTime = first * 60 + second; // Assume HH:MM } break; } } if (hospitalTime === null) hospitalTime = 999; // Unknown, assume long } // Get level from specific position if available const lvlCell = li.querySelector('[class*="lvl"], [class*="level"]'); if (lvlCell) { const m = lvlCell.textContent.match(/(\d+)/); if (m) level = parseInt(m[1], 10); } bounties.push({ id: userId, userId, name, level, reward, status, hospitalTime, rsi: null, beaten: null, valueScore: 0 }); }); // Log status breakdown const statusCounts = { Okay: 0, Hospital: 0, Traveling: 0 }; bounties.forEach(b => statusCounts[b.status] = (statusCounts[b.status] || 0) + 1); console.log('BountySniper: Status breakdown:', statusCounts); return bounties; } // Fetch bounty data using hidden iframe (needed because TORN uses React/SPA) // Scans multiple pages and uses "Hide Unavailable" filter async function fetchBountyPage() { const MAX_PAGES_TO_SCAN = Math.min(settings.sniperPagesToScan || 10, 20); // Use setting, max 20 const BOUNTIES_PER_PAGE = 20; // TORN shows 20 bounties per page const allBounties = []; const seenIds = new Set(); for (let page = 1; page <= MAX_PAGES_TO_SCAN; page++) { console.log(`BountySniper: Loading page ${page}/${MAX_PAGES_TO_SCAN}...`); updateStatus(`Loading page ${page}/${MAX_PAGES_TO_SCAN}...`); try { const pageBounties = await fetchBountyPageSingle(page); // Add unique bounties let newCount = 0; for (const b of pageBounties) { if (!seenIds.has(b.id)) { seenIds.add(b.id); allBounties.push(b); newCount++; } } console.log(`BountySniper: Page ${page} found ${pageBounties.length} bounties, ${newCount} new, total: ${allBounties.length}`); // If we got very few bounties, might be at end of filtered results if (pageBounties.length < 5) { console.log('BountySniper: Few results on page, stopping pagination'); break; } // Throttled delay between page loads to reduce load if (page < MAX_PAGES_TO_SCAN) { await new Promise(r => setTimeout(r, PAGE_LOAD_DELAY)); } } catch (err) { console.error(`BountySniper: Error loading page ${page}:`, err); break; } } // Log status breakdown from iframe fetching const statusCounts = { Okay: 0, Hospital: 0, Traveling: 0, Unknown: 0 }; allBounties.forEach(b => { statusCounts[b.status] = (statusCounts[b.status] || 0) + 1; }); console.log('BountySniper: Iframe total status breakdown:', statusCounts); return allBounties; } // Fetch a single page of bounties async function fetchBountyPageSingle(pageNum = 1) { return new Promise((resolve, reject) => { // Create hidden iframe const iframe = document.createElement('iframe'); iframe.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1024px;height:768px;visibility:hidden;'; // TORN pagination uses: #/!p=main&start=X where X = (page-1) * 20 const start = (pageNum - 1) * 20; const url = `https://www.torn.com/bounties.php#/!p=main&start=${start}`; console.log(`BountySniper: Loading iframe URL: ${url}`); iframe.src = url; let resolved = false; let checkInterval = null; let clickedHideUnavailable = false; const cleanup = () => { if (checkInterval) clearInterval(checkInterval); if (iframe.parentNode) iframe.remove(); }; const tryClickHideUnavailable = (doc) => { if (clickedHideUnavailable) return false; try { // Look for "Hide Unavailable" checkbox and click it const labels = doc.querySelectorAll('label'); for (const label of labels) { if (label.textContent.includes('Hide Unavailable')) { const checkbox = label.querySelector('input[type="checkbox"]'); if (checkbox && !checkbox.checked) { console.log('BountySniper: Clicking Hide Unavailable checkbox'); checkbox.click(); clickedHideUnavailable = true; return true; // Will need to wait for re-render } else if (checkbox && checkbox.checked) { clickedHideUnavailable = true; // Already checked } } } // Also try by finding any checkbox near "Hide" text const allCheckboxes = doc.querySelectorAll('input[type="checkbox"]'); for (const cb of allCheckboxes) { const parent = cb.closest('label') || cb.parentElement; if (parent && /hide.*unavailable/i.test(parent.textContent)) { if (!cb.checked) { cb.click(); clickedHideUnavailable = true; return true; } clickedHideUnavailable = true; } } } catch (e) { console.log('BountySniper: Could not click Hide Unavailable:', e); } return false; }; const tryParse = () => { try { const doc = iframe.contentDocument || iframe.contentWindow?.document; if (!doc) return null; // Look for profile links - if found, page has rendered const profileLinks = doc.querySelectorAll('a[href*="profiles.php?XID="]'); if (profileLinks.length > 0) { return doc; } } catch (e) { // Cross-origin error - shouldn't happen since same domain } return null; }; // Timeout after 30 seconds per page const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanup(); console.log(`BountySniper: Iframe timeout for page ${pageNum}`); reject(new Error('Iframe timeout')); } }, 30000); iframe.onload = () => { let attempts = 0; let waitingForRerender = false; checkInterval = setInterval(() => { attempts++; const doc = tryParse(); if (doc) { // Try to click Hide Unavailable on first detection if (!clickedHideUnavailable) { const clicked = tryClickHideUnavailable(doc); if (clicked) { // Wait for page to re-render after clicking waitingForRerender = true; attempts = 0; // Reset attempts counter return; } } // If we clicked the checkbox, wait a bit for re-render if (waitingForRerender && attempts < 8) { return; // Wait 4 more seconds (8 * 500ms) } if (!resolved) { resolved = true; clearTimeout(timeout); // Parse bounties from iframe DOM const bounties = parseBountyFromDoc(doc); console.log(`BountySniper: Page ${pageNum} parsed ${bounties.length} bounties`); cleanup(); resolve(bounties); } } else if (attempts > 50) { // Give up after 25 seconds if (!resolved) { resolved = true; clearTimeout(timeout); cleanup(); console.log(`BountySniper: Page ${pageNum} content never rendered`); reject(new Error('Content not rendered')); } } }, 500); }; iframe.onerror = () => { if (!resolved) { resolved = true; clearTimeout(timeout); cleanup(); reject(new Error('Iframe load error')); } }; document.body.appendChild(iframe); }); } // Parse bounties from a document (iframe or current page) function parseBountyFromDoc(doc) { const bounties = []; // Find all list items with profile links const rows = doc.querySelectorAll('li'); rows.forEach(li => { const profileLink = li.querySelector('a[href*="profiles.php?XID="]'); if (!profileLink) return; const userIdMatch = profileLink.href.match(/XID=(\d+)/); if (!userIdMatch) return; const userId = userIdMatch[1]; const name = profileLink.textContent.trim(); const liText = li.textContent.toLowerCase(); // Extract level let level = 0; const cells = li.querySelectorAll('div'); cells.forEach(cell => { const text = cell.textContent.trim(); if (/^\d+$/.test(text)) { const num = parseInt(text, 10); if (num >= 1 && num <= 100 && !level) level = num; } }); // Extract reward let reward = 0; const rewardMatch = li.textContent.match(/\$?([\d,]+)/); if (rewardMatch) { const num = parseInt(rewardMatch[1].replace(/,/g, ''), 10); if (num > 10000) reward = num; } // Extract status - be very explicit about detection let status = 'Unknown'; // Default to Unknown, not Okay let hospitalTime = null; // Method 1: Look for status links const hospitalLink = li.querySelector('a[href*="hospitalview"], a[class*="hospital"]'); const travelLink = li.querySelector('a[href*="abroad"], a[class*="traveling"], a[class*="abroad"]'); const okayLink = li.querySelector('a[class*="okay"], span[class*="okay"]'); // Method 2: Check for status text in cells for (const cell of cells) { const cellText = cell.textContent.trim().toLowerCase(); if (cellText === 'hospital') { status = 'Hospital'; break; } else if (cellText === 'traveling' || cellText === 'abroad') { status = 'Traveling'; break; } else if (cellText === 'okay') { status = 'Okay'; break; } } // Method 3: Check links if text didn't match if (status === 'Unknown') { if (hospitalLink) { status = 'Hospital'; } else if (travelLink) { status = 'Traveling'; } else if (okayLink) { status = 'Okay'; } } // Method 4: Check full row text for keywords if (status === 'Unknown') { if (liText.includes('hospital')) { status = 'Hospital'; } else if (liText.includes('traveling') || liText.includes('abroad')) { status = 'Traveling'; } else if (liText.includes('okay')) { status = 'Okay'; } } // Parse hospital time if applicable if (status === 'Hospital') { const timeMatch = li.textContent.match(/(\d+):(\d+):(\d+)/); if (timeMatch) { hospitalTime = parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10); } else { hospitalTime = 999; // Unknown, assume long } } bounties.push({ id: userId, userId, name, level, reward, status, hospitalTime, rsi: null, beaten: null, valueScore: 0 }); }); // Log status breakdown for this page const statusCounts = { Okay: 0, Hospital: 0, Traveling: 0, Unknown: 0 }; bounties.forEach(b => statusCounts[b.status] = (statusCounts[b.status] || 0) + 1); console.log('BountySniper: Page status breakdown:', statusCounts); return bounties; } // Check if we've beaten this player before async function checkBeatenStatus(userId) { if (beatenCache.has(userId)) { return beatenCache.get(userId); } try { const history = await FightDB.getOpponentHistory(userId); const wins = history.filter(f => f.outcome === 'win').length; const losses = history.filter(f => f.outcome === 'loss').length; const result = { wins, losses, total: history.length }; beatenCache.set(userId, result); return result; } catch (e) { return { wins: 0, losses: 0, total: 0 }; } } // Get RSI from cache or fetch - also returns status info // Set forceRefresh=true to bypass cache (used for panel target verification) async function getRsi(userId, forceRefresh = false) { // Check cache unless force refresh requested if (!forceRefresh) { const cached = rsiCache.get(userId); if (cached && (Date.now() - cached.timestamp) < RSI_CACHE_TTL) { return cached.rsi; } } // Check API budget if (!canUseApi()) { return null; } return new Promise((resolve) => { apiCallsThisMinute++; // Request all needed selections: personalstats for RSI calc, status for current state ApiManager.get(`/user/${userId}?selections=personalstats,basic,profile`, d => { if (!d) { console.log(`BountySniper: API returned null for ${userId}`); resolve(null); return; } // Calculate RSI const rsi = calcRSI(USER_BP, d.personalstats, d.basic, d.life); // Extract level and age from API response const playerLevel = d.level || d.basic?.level || 0; const playerAge = d.age || 0; // Age in days // Log level/age for debugging console.log(`BountySniper: ${d.name || userId} - Level: ${playerLevel}, Age: ${playerAge} days`); // Extract status - TORN API returns status object with state, description, until const statusObj = d.status || {}; const stateStr = statusObj.state || ''; const statusDesc = statusObj.description || ''; const statusUntil = statusObj.until || 0; // Log raw API response for debugging console.log(`BountySniper: API raw status for ${d.name || userId}:`, JSON.stringify(statusObj)); // Normalize status - be very explicit about detection let normalizedStatus = 'Unknown'; // Check state string first if (stateStr) { const stateLower = stateStr.toLowerCase(); if (stateLower.includes('hospital')) { normalizedStatus = 'Hospital'; } else if (stateLower.includes('travel') || stateLower.includes('abroad') || stateLower.includes('flying')) { normalizedStatus = 'Traveling'; } else if (stateLower === 'okay' || stateLower === 'online' || stateLower === 'offline' || stateLower === 'idle') { normalizedStatus = 'Okay'; } } // Also check description as backup if (normalizedStatus === 'Unknown' && statusDesc) { const descLower = statusDesc.toLowerCase(); if (descLower.includes('hospital')) { normalizedStatus = 'Hospital'; } else if (descLower.includes('travel') || descLower.includes('abroad')) { normalizedStatus = 'Traveling'; } } // If still unknown but we got a response, assume Okay if (normalizedStatus === 'Unknown' && d.name) { normalizedStatus = 'Okay'; } // Calculate hospital time remaining in minutes let hospitalMins = null; if (normalizedStatus === 'Hospital' && statusUntil) { const remainingMs = (statusUntil * 1000) - Date.now(); hospitalMins = Math.max(0, remainingMs / 60000); console.log(`BountySniper: ${d.name} hospital time: ${hospitalMins.toFixed(1)} mins remaining`); } const result = { adjusted: rsi.adjusted, raw: rsi.raw, life: d.life?.maximum ? Math.round((d.life.current / d.life.maximum) * 100) : 100, status: normalizedStatus, hospitalTime: hospitalMins, statusDescription: statusDesc, level: playerLevel, age: playerAge }; console.log(`BountySniper: API status for ${d.name || userId}: "${stateStr}" -> ${normalizedStatus}`); // Update cache rsiCache.set(userId, { rsi: result, timestamp: Date.now() }); resolve(result); }); }); } // Check if we can make an API call within budget function canUseApi() { // Reset counter every minute if (Date.now() - lastMinuteReset > 60000) { apiCallsThisMinute = 0; lastMinuteReset = Date.now(); } return apiCallsThisMinute < settings.sniperApiBudget; } // Calculate value score for a bounty function calculateValueScore(bounty) { // Base score from reward (normalized to 0-100) const rewardScore = Math.min(bounty.reward / 1000000, 1) * 40; // Max 40 points for $1M+ // RSI score (higher RSI = easier = better) let rsiScore = 0; if (bounty.rsi) { // RSI 100 = 50% win, RSI 150 = ~90% win, RSI 200+ = 100% win const winProb = Math.min(bounty.rsi.adjusted / 150, 1); rsiScore = winProb * 30; // Max 30 points } // Beaten bonus let beatenScore = 0; if (bounty.beaten && bounty.beaten.wins > 0) { const winRate = bounty.beaten.wins / (bounty.beaten.total || 1); beatenScore = winRate * 20; // Max 20 points } // Status penalty let statusPenalty = 0; if (bounty.status === 'Hospital') statusPenalty = 5; if (bounty.status === 'Traveling') statusPenalty = 15; return rewardScore + rsiScore + beatenScore - statusPenalty; } // Apply filters to bounty list // Pass 1 (strictHospitalFilter=false): Only filter by reward/level, let all statuses through for API verification // Pass 2 (strictHospitalFilter=true): Apply full status filters after API verification function filterBounties(bounties, strictHospitalFilter = false) { return bounties.filter(b => { // Reward filter - always apply if (b.reward < settings.sniperMinReward) return false; // Level filter - always apply if (b.level > settings.sniperMaxLevel && b.level > 0) return false; // RSI filter - only if we have RSI data if (b.rsi) { if (b.rsi.adjusted < settings.sniperRsiMin) return false; if (b.rsi.adjusted > settings.sniperRsiMax) return false; } // STATUS FILTERS - Only apply in strict mode (after API verification) // Page-scraped status is unreliable, so we let everything through initially if (!strictHospitalFilter) { // Non-strict mode: let all statuses through for API verification // Only filter out if we KNOW it's bad (has API data already) if (b.rsi && b.rsi.status) { // We have API-verified status, can apply some filters if (b.rsi.status === 'Traveling' && !settings.sniperShowTraveling) { return false; } } return true; } // STRICT MODE - Apply full status filters (after API verification) // Unknown status after API check = filter out if (b.status === 'Unknown') { console.log(`BountySniper: Filtering out ${b.name} - unknown status`); return false; } // Okay status if (b.status === 'Okay') { if (!settings.sniperShowOkay) { console.log(`BountySniper: Filtering out ${b.name} - okay (setting disabled)`); return false; } } // Hospital status if (b.status === 'Hospital') { if (!settings.sniperShowHospital) { console.log(`BountySniper: Filtering out ${b.name} - hospital (setting disabled)`); return false; } // Hospital time filter if (b.hospitalTime !== null && b.hospitalTime > settings.sniperHospitalMins) { console.log(`BountySniper: Filtering out ${b.name} - hospital time ${b.hospitalTime.toFixed(1)} mins > ${settings.sniperHospitalMins} threshold`); return false; } // If we still don't have hospital time after API check, filter out if (b.hospitalTime === null || b.hospitalTime === 999) { console.log(`BountySniper: Filtering out ${b.name} - hospital time unknown`); return false; } } // Traveling status if (b.status === 'Traveling') { if (!settings.sniperShowTraveling) { console.log(`BountySniper: Filtering out ${b.name} - traveling (setting disabled)`); return false; } } return true; }); } // Sort targets based on user preference function sortTargets(targets) { const sortBy = settings.sniperSortBy; return targets.sort((a, b) => { switch (sortBy) { case 'reward': return b.reward - a.reward; case 'rsi': return (b.rsi?.adjusted || 0) - (a.rsi?.adjusted || 0); case 'beaten': return (b.beaten?.wins || 0) - (a.beaten?.wins || 0); case 'value': default: return b.valueScore - a.valueScore; } }); } // Main scan function - uses progressive scanning async function scan() { if (!isEnabled || !API_KEY) { console.log('BountySniper: Not scanning - disabled or no API key'); return; } // Update activity time for watchdog lastActivityTime = Date.now(); try { const now = Date.now(); const needsBountyRefresh = (now - lastBountyFetch) > BOUNTY_REFRESH_INTERVAL || cachedCandidates.length === 0; // PHASE 1: Refresh bounty list if needed (every 3 minutes or if empty) if (needsBountyRefresh) { updateStatus('Refreshing bounty list...'); console.log('BountySniper: Refreshing bounty list...'); let bounties; // If on bounty page, scrape DOM directly (fastest) if (location.href.includes('bounties.php')) { console.log('BountySniper: Scraping bounty page DOM'); bounties = parseBountyDOM(); } else { // Use iframe to load bounty page and scrape after React renders console.log('BountySniper: Loading bounty page via iframe...'); try { bounties = await fetchBountyPage(); } catch (fetchErr) { console.error('BountySniper: Fetch failed:', fetchErr); updateStatus('Fetch failed'); return; } } console.log('BountySniper: Parsed', bounties.length, 'bounties'); if (bounties.length === 0) { updateStatus('No bounties found'); renderPanel(); return; } // Deduplicate by userId (keep highest reward entry) const bountyMap = new Map(); for (const b of bounties) { const existing = bountyMap.get(b.userId); if (!existing || b.reward > existing.reward) { bountyMap.set(b.userId, b); } } cachedBounties = Array.from(bountyMap.values()); console.log('BountySniper: After deduplication:', cachedBounties.length, 'unique bounties'); // Apply initial filter (non-strict, lets unknown hospital times through) cachedCandidates = filterBounties(cachedBounties, false); console.log('BountySniper: After initial filtering:', cachedCandidates.length, 'candidates'); // Check beaten status for all candidates (uses local DB, no API) for (const bounty of cachedCandidates) { bounty.beaten = await checkBeatenStatus(bounty.userId); } // Sort candidates for processing order // 1. Prioritize candidates matching user's enabled statuses (page-scraped hint) // 2. Not yet checked (no RSI cache) // 3. Okay status first // 4. Beaten before // 5. By reward cachedCandidates.sort((a, b) => { // First: prioritize candidates matching enabled status settings // This uses page-scraped status as a HINT (not a filter) const aMatchesPrefs = statusMatchesPrefs(a.status); const bMatchesPrefs = statusMatchesPrefs(b.status); if (aMatchesPrefs && !bMatchesPrefs) return -1; if (!aMatchesPrefs && bMatchesPrefs) return 1; // Then: unchecked candidates const aHasCache = rsiCache.has(a.userId) && (now - rsiCache.get(a.userId).timestamp) < RSI_CACHE_TTL; const bHasCache = rsiCache.has(b.userId) && (now - rsiCache.get(b.userId).timestamp) < RSI_CACHE_TTL; if (!aHasCache && bHasCache) return -1; if (aHasCache && !bHasCache) return 1; // Okay status gets priority if (a.status === 'Okay' && b.status !== 'Okay') return -1; if (a.status !== 'Okay' && b.status === 'Okay') return 1; // Then beaten before if (a.beaten?.wins && !b.beaten?.wins) return -1; if (!a.beaten?.wins && b.beaten?.wins) return 1; // Then by reward return b.reward - a.reward; }); // Reset scan index on refresh scanIndex = 0; lastBountyFetch = now; // Clean up verified targets - remove those no longer on bounty list const currentUserIds = new Set(cachedBounties.map(b => b.userId)); for (const userId of verifiedTargets.keys()) { if (!currentUserIds.has(userId)) { verifiedTargets.delete(userId); } } } // PHASE 1.5: Verify existing panel targets are still valid // This runs FIRST to ensure panel accuracy (uses up to 5 API calls for 5 targets) if (verifiedTargets.size > 0) { updateStatus('Verifying targets...'); const verifyApiCalls = await verifyPanelTargets(); console.log(`BountySniper: Target verification used ${verifyApiCalls} API calls`); } // PHASE 2: Progressive API checking from current scan position if (cachedCandidates.length === 0) { updateStatus('No candidates'); renderPanel(); return; } const budget = settings.sniperApiBudget; let apiCallsMade = 0; let checkedThisCycle = 0; const startIndex = scanIndex; console.log(`BountySniper: Starting API check from index ${scanIndex}/${cachedCandidates.length}, budget: ${budget}`); updateStatus(`Checking ${scanIndex}/${cachedCandidates.length}...`); // Process candidates starting from scanIndex while (checkedThisCycle < cachedCandidates.length && canUseApi()) { const idx = (startIndex + checkedThisCycle) % cachedCandidates.length; const bounty = cachedCandidates[idx]; checkedThisCycle++; // Check if already cached const cached = rsiCache.get(bounty.userId); if (cached && (Date.now() - cached.timestamp) < RSI_CACHE_TTL) { // Use cached data bounty.rsi = cached.rsi; bounty.status = cached.rsi.status || bounty.status; bounty.hospitalTime = cached.rsi.hospitalTime; // If this is a verified good target, update in verified map const result = isGoodTarget(bounty); if (result.valid) { bounty.valueScore = calculateValueScore(bounty); verifiedTargets.set(bounty.userId, bounty); } else { verifiedTargets.delete(bounty.userId); } continue; } // Need to make API call bounty.rsi = await getRsi(bounty.userId); apiCallsMade++; if (bounty.rsi) { bounty.status = bounty.rsi.status || bounty.status; bounty.hospitalTime = bounty.rsi.hospitalTime; // Check if this is a good target const result = isGoodTarget(bounty); if (result.valid) { bounty.valueScore = calculateValueScore(bounty); verifiedTargets.set(bounty.userId, bounty); console.log(`BountySniper: ✓ Good target: ${bounty.name} (${bounty.status}, RSI: ${bounty.rsi.adjusted?.toFixed(0) || '?'}%)`); } else { verifiedTargets.delete(bounty.userId); console.log(`BountySniper: ✗ Filtered: ${bounty.name} - ${result.reason}`); } } // Throttled delay between API calls await new Promise(r => setTimeout(r, API_CALL_DELAY)); } // Update scan index for next cycle scanIndex = (startIndex + checkedThisCycle) % cachedCandidates.length; const progress = Math.round((getCheckedCount() / cachedCandidates.length) * 100); const cacheHits = checkedThisCycle - apiCallsMade; // Detailed logging for debugging if (apiCallsMade === 0 && checkedThisCycle > 0) { console.log(`BountySniper: All ${checkedThisCycle} candidates used cached data (${cacheHits} cache hits, progress: ${progress}%)`); } else if (checkedThisCycle === 0) { console.log(`BountySniper: No candidates checked this cycle (API budget: ${apiCallsThisMinute}/${settings.sniperApiBudget}, progress: ${progress}%)`); } else { console.log(`BountySniper: Made ${apiCallsMade} API calls, ${cacheHits} cache hits, checked ${checkedThisCycle} candidates, progress: ${progress}%`); } // If we hit 100% and bounties were refreshed, clear cache for removed players if (progress === 100 && needsBountyRefresh) { const currentIds = new Set(cachedCandidates.map(c => c.userId)); let clearedCount = 0; for (const [userId] of rsiCache) { if (!currentIds.has(userId)) { rsiCache.delete(userId); clearedCount++; } } if (clearedCount > 0) { console.log(`BountySniper: Cleared ${clearedCount} stale cache entries for removed bounties`); } } // PHASE 3: Build targets from verified targets // Save current target IDs BEFORE updating for sound alert comparison const previousTargetIds = new Set(targets.map(t => t.userId)); const verifiedArray = Array.from(verifiedTargets.values()); targets = sortTargets(verifiedArray).slice(0, MAX_TARGETS); // Check for NEW targets that just appeared in the panel const newTargetsInPanel = targets.filter(t => !previousTargetIds.has(t.userId)); if (newTargetsInPanel.length > 0) { console.log(`BountySniper: 🆕 New targets detected: ${newTargetsInPanel.map(t => t.name).join(', ')}`); if (settings.sniperSoundAlert) { playAlertSound(); } } // Update status const statusMsg = `${targets.length} targets | ${progress}% scanned`; updateStatus(statusMsg); // Broadcast targets to other tabs broadcastTargets(); // Update UI renderPanel(); } catch (e) { console.error('BountySniper: Scan error', e); updateStatus('Error: ' + (e.message || 'Unknown')); } } // Check if a bounty passes all filters (after API verification) function isGoodTarget(bounty) { // Must have RSI data if (!bounty.rsi) { return { valid: false, reason: 'no RSI data' }; } // NEW PLAYER PROTECTION: Cannot attack players level 15 or below const playerLevel = bounty.rsi.level || bounty.level || 0; if (playerLevel > 0 && playerLevel <= 15) { return { valid: false, reason: `Level ${playerLevel} ≤ 15 (protected)` }; } // NEW PLAYER PROTECTION: Cannot attack players 15 days old or less const playerAge = bounty.rsi.age || 0; if (playerAge > 0 && playerAge <= 15) { return { valid: false, reason: `Age ${playerAge} days ≤ 15 (protected)` }; } // RSI filter - add null safety const rsiValue = bounty.rsi.adjusted; if (rsiValue === null || rsiValue === undefined) { return { valid: false, reason: 'RSI value is null' }; } if (rsiValue < settings.sniperRsiMin) { return { valid: false, reason: `RSI ${rsiValue.toFixed(0)}% < ${settings.sniperRsiMin}% min` }; } if (rsiValue > settings.sniperRsiMax) { return { valid: false, reason: `RSI ${rsiValue.toFixed(0)}% > ${settings.sniperRsiMax}% max` }; } // Status filter if (bounty.status === 'Unknown') { return { valid: false, reason: 'unknown status' }; } if (bounty.status === 'Okay') { if (!settings.sniperShowOkay) { return { valid: false, reason: 'Okay (setting disabled)' }; } } if (bounty.status === 'Hospital') { if (!settings.sniperShowHospital) { return { valid: false, reason: 'Hospital (setting disabled)' }; } if (bounty.hospitalTime === null || bounty.hospitalTime > settings.sniperHospitalMins) { return { valid: false, reason: `Hospital ${bounty.hospitalTime?.toFixed(0) || '?'} mins > ${settings.sniperHospitalMins} threshold` }; } } if (bounty.status === 'Traveling') { if (!settings.sniperShowTraveling) { return { valid: false, reason: 'Traveling (setting disabled)' }; } } return { valid: true, reason: null }; } // Count how many candidates have been checked (have fresh cache) function getCheckedCount() { const now = Date.now(); return cachedCandidates.filter(c => { const cached = rsiCache.get(c.userId); return cached && (now - cached.timestamp) < RSI_CACHE_TTL; }).length; } // Verify targets currently in panel are still valid // Returns number of API calls made async function verifyPanelTargets() { const now = Date.now(); let apiCallsMade = 0; const targetsToRemove = []; for (const [userId, target] of verifiedTargets) { // Check if target needs re-verification (every 60 seconds) const lastVerified = targetLastVerified.get(userId) || 0; if ((now - lastVerified) < TARGET_VERIFY_TTL) { continue; // Recently verified, skip } // Check API budget if (!canUseApi()) { console.log('BountySniper: API budget exhausted during target verification'); break; } // Re-fetch player data with FORCE REFRESH to bypass cache console.log(`BountySniper: Verifying target: ${target.name} (forcing fresh API call)`); const rsi = await getRsi(userId, true); // true = force refresh, bypass cache apiCallsMade++; targetLastVerified.set(userId, now); if (!rsi) { console.log(`BountySniper: ⚠ Could not verify ${target.name} - API error`); continue; // Keep target for now, will retry next cycle } // Log the status change detection const oldStatus = target.status; const newStatus = rsi.status; if (oldStatus !== newStatus) { console.log(`BountySniper: ⚡ Status CHANGED for ${target.name}: ${oldStatus} -> ${newStatus}`); } // Update target data with fresh API results target.rsi = rsi; target.status = rsi.status; target.hospitalTime = rsi.hospitalTime; // Check if still valid against current settings const result = isGoodTarget(target); if (!result.valid) { console.log(`BountySniper: 🔴 Target no longer valid: ${target.name} - ${result.reason}`); targetsToRemove.push(userId); } else { console.log(`BountySniper: ✅ Target still valid: ${target.name} (${target.status}, RSI: ${rsi.adjusted?.toFixed(0) || '?'}%)`); target.valueScore = calculateValueScore(target); } // Throttled delay between API calls await new Promise(r => setTimeout(r, API_CALL_DELAY)); } // Remove invalid targets for (const userId of targetsToRemove) { verifiedTargets.delete(userId); targetLastVerified.delete(userId); } // Also remove from targets array if (targetsToRemove.length > 0) { targets = targets.filter(t => !targetsToRemove.includes(t.userId)); renderPanel(); broadcastTargets(); } return apiCallsMade; } // Audio context (persistent, unlocked on user gesture) let audioCtx = null; let audioUnlocked = false; // Unlock audio on first user interaction function unlockAudio() { if (audioUnlocked) return; try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); // Create and play a silent buffer to unlock const buffer = audioCtx.createBuffer(1, 1, 22050); const source = audioCtx.createBufferSource(); source.buffer = buffer; source.connect(audioCtx.destination); source.start(0); audioUnlocked = true; console.log('BountySniper: Audio unlocked'); } catch (e) { console.log('BountySniper: Audio unlock failed', e); } } // Play alert sound function playAlertSound() { try { // Create context if needed if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } // Resume if suspended (browser policy) if (audioCtx.state === 'suspended') { audioCtx.resume().then(() => { playBeep(); }).catch(e => { console.log('BountySniper: Could not resume audio context', e); }); } else { playBeep(); } } catch (e) { console.log('BountySniper: Audio not available', e); } } // Actually play the beep sound function playBeep() { if (!audioCtx) return; try { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); // Two-tone alert (more noticeable) osc.frequency.value = 880; // A5 gain.gain.value = 0.3; osc.start(); // Frequency sweep for attention osc.frequency.setValueAtTime(880, audioCtx.currentTime); osc.frequency.setValueAtTime(1100, audioCtx.currentTime + 0.1); osc.frequency.setValueAtTime(880, audioCtx.currentTime + 0.2); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); osc.stop(audioCtx.currentTime + 0.3); console.log('BountySniper: 🔊 Alert sound played'); } catch (e) { console.log('BountySniper: Beep failed', e); } } // Format reward for display function formatReward(reward) { if (reward >= 1000000) return `$${(reward / 1000000).toFixed(1)}M`; if (reward >= 1000) return `$${Math.round(reward / 1000)}k`; return `$${reward}`; } // Update status text in panel function updateStatus(text) { if (!panelEl) return; const status = panelEl.querySelector('.ff-sniper-status'); if (status) { const roleIndicator = isLeader ? '●' : '●'; const budget = `${apiCallsThisMinute}/${settings.sniperApiBudget}`; status.innerHTML = `${roleIndicator} ${text} | API: ${budget}/min`; } } // Render the sniper panel UI function renderPanel() { if (!panelEl) { createPanel(); } const badge = panelEl.querySelector('.ff-sniper-badge'); const list = panelEl.querySelector('.ff-sniper-list'); const status = panelEl.querySelector('.ff-sniper-status'); // Update badge count if (badge) { badge.textContent = targets.length; badge.className = `ff-sniper-badge${targets.length === 0 ? ' empty' : ''}`; } // Update list if (list) { if (targets.length === 0) { list.innerHTML = '
No targets found
'; } else { list.innerHTML = targets.map(t => { const rsiClass = !t.rsi ? '' : t.rsi.adjusted < settings.lowHigh ? 'high' : t.rsi.adjusted < settings.highMed ? 'med' : 'low'; const rsiText = t.rsi ? `${Math.round(t.rsi.adjusted)}%` : '...'; const beatenIcon = t.beaten?.wins > 0 ? `⭐` : ''; return `
${rsiText} ${escapeHtml(t.name)} ${beatenIcon} ${formatReward(t.reward)} ATK
`; }).join(''); } } // Update status if (status) { const budget = `${apiCallsThisMinute}/${settings.sniperApiBudget}`; status.innerHTML = `Scanning | API: ${budget}/min`; } } // Create the panel element function createPanel() { // DUPLICATE PREVENTION: Check if panel already exists if (panelCreated || document.querySelector('.ff-sniper-panel')) { console.log('BountySniper: Panel already exists, skipping creation'); // If panelEl is null but DOM has panel, grab reference if (!panelEl) { panelEl = document.querySelector('.ff-sniper-panel'); } return; } // Load saved collapsed state try { const savedCollapsed = localStorage.getItem(STORAGE_KEY_COLLAPSED); if (savedCollapsed !== null) { isCollapsed = JSON.parse(savedCollapsed); } } catch (e) {} panelEl = document.createElement('div'); panelEl.className = 'ff-sniper-panel' + (isCollapsed ? ' collapsed' : '') + (!isLeader ? ' follower' : ''); panelEl.setAttribute('data-tab-id', TAB_ID); // Mark which tab created this panelEl.innerHTML = `
🎯 Sniper 0 📡 ${isCollapsed ? '▶' : '▼'}
Initializing...
`; // Toggle collapse on toggle button click only (not whole header - that's for drag) const toggle = panelEl.querySelector('.ff-sniper-toggle'); toggle.addEventListener('click', (e) => { e.stopPropagation(); unlockAudio(); // Unlock audio on user interaction isCollapsed = !isCollapsed; panelEl.classList.toggle('collapsed', isCollapsed); toggle.textContent = isCollapsed ? '▶' : '▼'; // Save collapsed state try { localStorage.setItem(STORAGE_KEY_COLLAPSED, JSON.stringify(isCollapsed)); } catch (err) {} }); // Unlock audio on any click on the panel panelEl.addEventListener('click', () => { unlockAudio(); }, { once: true }); // Only need to unlock once // Click satellite icon to become leader (only visible on followers) const leaderBtn = panelEl.querySelector('.ff-sniper-leader-btn'); leaderBtn.addEventListener('click', (e) => { e.stopPropagation(); if (!isLeader) { forceLeadership(); } }); document.body.appendChild(panelEl); panelCreated = true; // Mark as created // Apply saved position const pos = getSavedPosition(); applyPosition(pos.x, pos.y); // Setup drag listeners setupDragListeners(); // Update role display updatePanelRole(); // Unlock audio on any document interaction (for sound alerts) document.addEventListener('click', unlockAudio, { once: true }); document.addEventListener('touchstart', unlockAudio, { once: true }); } // Start the sniper function start() { if (!settings.sniperEnabled) return; console.log(`BountySniper: Starting on ${isMobilePDA ? 'MOBILE/PDA' : 'DESKTOP'} (Tab: ${TAB_ID})`); // DUPLICATE PREVENTION: Check for existing panels on page const existingPanels = document.querySelectorAll('.ff-sniper-panel'); if (existingPanels.length > 1) { console.log('BountySniper: Multiple panels detected, removing extras'); for (let i = 1; i < existingPanels.length; i++) { existingPanels[i].remove(); } } isEnabled = true; receivedUpdatesDuringDelay = false; // Check if there's an ACTIVE leader (heartbeat within timeout) - another tab scanning const currentLeader = localStorage.getItem(STORAGE_KEY_LEADER); const lastHeartbeat = parseInt(localStorage.getItem(STORAGE_KEY_HEARTBEAT) || '0', 10); const heartbeatAge = Date.now() - lastHeartbeat; const hasActiveLeader = currentLeader && currentLeader !== TAB_ID && heartbeatAge < LEADER_TIMEOUT; console.log(`BountySniper: Leadership check - activeLeader: ${hasActiveLeader}, heartbeatAge: ${(heartbeatAge/1000).toFixed(0)}s`); // ALWAYS try to load saved state - loadFullState() has built-in 5-minute freshness check // Returns true if fresh data was loaded, false if stale/missing const hasFreshState = loadFullState(); console.log(`BountySniper: loadFullState returned ${hasFreshState} (has fresh saved data: ${hasFreshState})`); if (!hasFreshState) { // No fresh state - clear any stale data and start fresh console.log('BountySniper: No fresh state - starting clean'); targets = []; cachedCandidates = []; cachedBounties = []; verifiedTargets.clear(); rsiCache.clear(); } // Setup cross-tab sync setupStorageListener(); // Create panel if (!panelEl) { createPanel(); } panelEl.style.display = 'block'; // Mark navigation for future page loads window.addEventListener('beforeunload', markNavigating); window.addEventListener('pagehide', markNavigating); // DECISION: Should we delay leadership claim? // Delay if: another tab is actively leading, OR we have fresh state to preserve const shouldDelayLeadership = hasActiveLeader || hasFreshState; console.log(`BountySniper: shouldDelay=${shouldDelayLeadership} (activeLeader=${hasActiveLeader}, hasFreshState=${hasFreshState})`); if (shouldDelayLeadership) { // Start as provisional follower - preserve data, wait before claiming leadership isProvisionalFollower = true; isLeader = false; updatePanelRole(); renderPanel(); updateStatus(`${targets.length} targets | Syncing...`); console.log(`BountySniper: Starting as PROVISIONAL FOLLOWER (delay: ${LEADERSHIP_CLAIM_DELAY/1000}s)`); // Start heartbeat checking (storage events will handle target updates) heartbeatInterval = setInterval(() => { if (isLeader) { checkLeader(); } }, HEARTBEAT_INTERVAL); // DELAYED LEADERSHIP CLAIM leadershipClaimTimer = setTimeout(() => { isProvisionalFollower = false; // Check if we received updates during the delay if (receivedUpdatesDuringDelay) { console.log('BountySniper: Received updates during delay, staying as follower'); // Continue as follower, check if leader is still alive checkLeader(); } else { // No updates received, check if we should become leader const nowHeartbeat = parseInt(localStorage.getItem(STORAGE_KEY_HEARTBEAT) || '0', 10); const nowAge = Date.now() - nowHeartbeat; if (nowAge > LEADER_TIMEOUT) { console.log(`BountySniper: No leader activity for ${(nowAge/1000).toFixed(0)}s, claiming leadership`); tryBecomeLeader(); // This now starts scanning if we become leader } else { console.log('BountySniper: Leader is active, remaining as follower'); } } // Setup visibility tracking after delay (for both mobile and desktop) setupVisibilityTracking(); }, LEADERSHIP_CLAIM_DELAY); } else { // No active leader: immediate start as leader console.log('BountySniper: No active leader detected, starting as LEADER immediately'); isProvisionalFollower = false; tryBecomeLeader(); // This now starts scanning if we become leader // Start heartbeat interval heartbeatInterval = setInterval(() => { checkLeader(); }, HEARTBEAT_INTERVAL); // Setup visibility tracking setupVisibilityTracking(); // Render empty panel renderPanel(); updateStatus('Starting scan...'); } // Setup watchdog for crash recovery setupWatchdog(); } // Start scanning as leader (extracted for reuse) function startLeaderScanning() { scan(); // Set up scan interval const intervalMs = settings.sniperFetchInterval * 1000; if (scanInterval) { clearInterval(scanInterval); } scanInterval = setInterval(() => { if (isLeader) { scan(); broadcastScanState(); } }, intervalMs); console.log(`BountySniper: Started as LEADER (interval: ${settings.sniperFetchInterval}s, budget: ${settings.sniperApiBudget}/min)`); } // Stop the sniper function stop() { isEnabled = false; if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (visibilityCheckInterval) { clearInterval(visibilityCheckInterval); visibilityCheckInterval = null; } if (watchdogInterval) { clearInterval(watchdogInterval); watchdogInterval = null; } if (leadershipClaimTimer) { clearTimeout(leadershipClaimTimer); leadershipClaimTimer = null; } // Save state before stopping (for mobile navigation) if (isLeader) { saveFullState(); localStorage.removeItem(STORAGE_KEY_LEADER); isLeader = false; } if (panelEl) { panelEl.style.display = 'none'; } console.log('BountySniper: Stopped'); } // Toggle sniper on/off function toggle(enabled) { if (enabled) { start(); } else { stop(); } } // Cleanup when tab is closing function cleanup() { // Save state before leaving (critical for mobile) if (isLeader) { saveFullState(); markNavigating(); // Give up leadership so other tabs can take over localStorage.removeItem(STORAGE_KEY_LEADER); console.log('BountySniper: Saved state and released leadership on tab close'); } else { markNavigating(); } } // Listen for tab close/unload window.addEventListener('beforeunload', cleanup); window.addEventListener('pagehide', cleanup); // Public API return { start, stop, toggle, scan, getTargets: () => targets, isRunning: () => isEnabled, isLeader: () => isLeader, getTabId: () => TAB_ID }; })(); // ============================================================================ // JAIL BUST SNIPER // ============================================================================ const JailBustSniper = (() => { // State let isEnabled = false; let panelEl = null; let panelCreated = false; let targets = []; let cachedPrisoners = []; let scanIndex = 0; let lastJailFetch = 0; let scanInterval = null; let isCollapsed = false; // Multi-tab coordination let isLeader = false; const TAB_ID = `bust-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const STORAGE_KEY_LEADER = 'ff-bust-leader'; const STORAGE_KEY_TARGETS = 'ff-bust-targets'; const STORAGE_KEY_POSITION = 'ff-bust-sniper-pos'; const STORAGE_KEY_COLLAPSED = 'ff-bust-sniper-collapsed'; const LEADER_HEARTBEAT_INTERVAL = 5000; // Reduced from 2000 to 5000 (less aggressive) const LEADER_TIMEOUT = 12000; // Increased from 5000 to 12000 (more tolerance) // Constants const MAX_TARGETS = settings.bustSniperMaxTargets || 10; const SCAN_INTERVAL = (settings.bustSniperFetchInterval || 30) * 1000; const PAGE_LOAD_DELAY = 2000; const isMobilePDA = window.innerWidth <= 768 || Env.isPDA; // Audio context let audioCtx = null; let audioUnlocked = false; /** * Check if this tab should be the leader */ function electLeader() { const stored = localStorage.getItem(STORAGE_KEY_LEADER); if (!stored) { // No leader exists, become leader isLeader = true; saveLeaderInfo(); console.log('JailBustSniper: Elected as leader (no existing leader)'); return; } try { const leaderInfo = JSON.parse(stored); const now = Date.now(); // Check if leader is still alive if (now - leaderInfo.heartbeat < LEADER_TIMEOUT) { // Leader is active if (leaderInfo.tabId === TAB_ID) { isLeader = true; } else { isLeader = false; console.log('JailBustSniper: Following existing leader'); } } else { // Leader is dead, take over isLeader = true; saveLeaderInfo(); console.log('JailBustSniper: Elected as leader (previous leader timeout)'); } } catch (e) { // Corrupted data, become leader isLeader = true; saveLeaderInfo(); } } /** * Force this tab to become leader */ function forceLeadership() { isLeader = true; saveLeaderInfo(); console.log('JailBustSniper: Forced leadership'); updatePanelRole(); scan(); // Immediately scan as new leader } /** * Save leader information */ function saveLeaderInfo() { if (isLeader) { try { localStorage.setItem(STORAGE_KEY_LEADER, JSON.stringify({ tabId: TAB_ID, heartbeat: Date.now() })); } catch (e) { console.error('JailBustSniper: Failed to save leader info:', e); } } } /** * Broadcast targets to other tabs */ function broadcastTargets() { if (isLeader) { localStorage.setItem(STORAGE_KEY_TARGETS, JSON.stringify({ targets, timestamp: Date.now(), leaderId: TAB_ID })); } } /** * Listen for target updates from leader */ function listenForTargets() { window.addEventListener('storage', (e) => { if (e.key === STORAGE_KEY_TARGETS && !isLeader) { try { const data = JSON.parse(e.newValue); if (data && data.targets) { targets = data.targets; renderPanel(); } } catch (err) { console.error('JailBustSniper: Error parsing targets', err); } } }); } /** * Update panel to show leader/follower status */ function updatePanelRole() { if (!panelEl) return; if (isLeader) { panelEl.classList.remove('follower'); } else { panelEl.classList.add('follower'); } const leaderBtn = panelEl.querySelector('.ff-bust-leader-btn'); if (leaderBtn) { leaderBtn.style.display = isLeader ? 'none' : 'inline'; } } /** * Parse jail page DOM to extract prisoner data * ENHANCED with extensive debugging and multiple parsing strategies */ /** * Parse jail page DOM to extract prisoner data * SMART TABLE PARSER - Detects column structure and extracts data correctly */ function parseJailFromDoc(doc, pageNum = 1) { const prisoners = []; console.log('JailBustSniper: 🔍 Smart Table Parser - Starting analysis...'); // STEP 1: Find the jail list table/container // Try multiple selectors for the main jail list let jailContainer = doc.querySelector('ul.jail-list, ul.users-list, ul[class*="jail"], ul[class*="user"], div.user-info-list-wrap, .content-wrapper'); if (!jailContainer) { // Fallback: find any list with user links const userLinks = doc.querySelectorAll('a[href*="profiles.php?XID="]'); if (userLinks.length > 0) { jailContainer = userLinks[0].closest('ul, div[class*="list"], div[class*="wrap"]') || doc.body; } else { console.log('JailBustSniper: ❌ No jail list found'); return prisoners; } } console.log(`JailBustSniper: Found jail container: ${jailContainer.className || jailContainer.tagName}`); // STEP 2: Find all prisoner rows // Try multiple selectors let rows = jailContainer.querySelectorAll('li[class*="user"], li'); if (rows.length === 0) { rows = jailContainer.querySelectorAll('div[class*="user-info"]'); } if (rows.length === 0) { rows = jailContainer.querySelectorAll('tr'); } console.log(`JailBustSniper: Found ${rows.length} potential prisoner rows`); // STEP 3: Analyze first row to understand structure if (rows.length > 0) { const firstRow = rows[0]; const rowText = firstRow.textContent; console.log(`JailBustSniper: 📊 Sample row text: "${rowText.substring(0, 200)}..."`); // Check what data patterns exist const hasTimePattern = /\d+[hm]\s*\d*[m]?/.test(rowText); const hasLevelNumber = /\d{1,3}/.test(rowText); console.log(`JailBustSniper: Row analysis - Has time pattern: ${hasTimePattern}, Has numbers: ${hasLevelNumber}`); } // STEP 4: Parse each row with intelligent extraction let successCount = 0; let failCount = 0; rows.forEach((row, index) => { try { // === EXTRACT USER ID AND NAME === const profileLink = row.querySelector('a[href*="profiles.php?XID="]'); if (!profileLink) { failCount++; return; } const userIdMatch = profileLink.href.match(/XID=(\d+)/); if (!userIdMatch) { failCount++; return; } const userId = userIdMatch[1]; let name = profileLink.textContent.trim(); // Clean up name (remove extra whitespace, icons, etc.) name = name.replace(/[\n\r\t]+/g, ' ').replace(/\s+/g, ' ').trim(); // === EXTRACT SENTENCE TIME === let sentence = 0; const rowText = row.textContent; // Strategy: Find time patterns (handle various formats) // Examples: "51h 18m", "5h", "45m", "2h 30m", "1h18m" const timePatterns = [ { regex: /(\d+)\s*h\s*(\d+)\s*m/i, parse: m => parseInt(m[1]) * 60 + parseInt(m[2]) }, { regex: /(\d+)\s*h\s*(\d+)/i, parse: m => parseInt(m[1]) * 60 + parseInt(m[2]) }, { regex: /(\d+)h(\d+)m/i, parse: m => parseInt(m[1]) * 60 + parseInt(m[2]) }, { regex: /(\d+)\s*h/i, parse: m => parseInt(m[1]) * 60 }, { regex: /(\d+)\s*m/i, parse: m => parseInt(m[1]) } ]; for (const { regex, parse } of timePatterns) { const match = rowText.match(regex); if (match) { sentence = parse(match); if (sentence > 0 && sentence < 20000) { // Sanity check (max ~333 hours) break; } } } // === EXTRACT LEVEL === let level = 0; // Strategy 1: Look in specific elements that typically hold level // Check all divs, spans, and tds for standalone numbers const allElements = row.querySelectorAll('div, span, td, li'); const numberCandidates = []; for (const el of allElements) { const text = el.textContent.trim(); // Look for text that is ONLY a number (with optional label) if (/^(Level\s*)?(\d{1,3})$/i.test(text)) { const num = parseInt(text.match(/\d+/)[0], 10); if (num >= 1 && num <= 100) { numberCandidates.push({ num, element: el.tagName, text }); } } } // If we found candidates, pick the most likely one // Preference: numbers in the middle range (levels are usually 1-100) if (numberCandidates.length > 0) { // Sort by how "level-like" they are (favor 1-100 range) numberCandidates.sort((a, b) => { // Prefer numbers in typical level range const scoreA = (a.num >= 1 && a.num <= 100) ? 100 - Math.abs(50 - a.num) : 0; const scoreB = (b.num >= 1 && b.num <= 100) ? 100 - Math.abs(50 - b.num) : 0; return scoreB - scoreA; }); level = numberCandidates[0].num; } // Strategy 2: Look for "Level X" pattern in text if (!level) { const levelMatch = rowText.match(/Level[:\s]+(\d+)/i); if (levelMatch) { level = parseInt(levelMatch[1], 10); } } // === DETECT JAIL TYPE === let jailType = 'normal'; const lowerText = rowText.toLowerCase(); if (lowerText.includes('federal') || lowerText.includes('fed jail') || row.querySelector('[class*="federal"]') || row.querySelector('img[alt*="federal"]')) { jailType = 'federal'; } // === FIND TORN'S BUST BUTTON IN THIS ROW === let bustButton = null; // Strategy 1: Find link with action=rescue, step=breakout, and matching XID // CRITICAL: Must have "breakout" in URL, NOT "buy" (which is the buy-out button) const links = row.querySelectorAll('a[href*="rescue"], a[href*="action=rescue"], a.bust, a[class*="bust"]'); for (const link of links) { if (link.href && link.href.includes(`XID=${userId}`) && link.href.includes('rescue') && link.href.includes('breakout') && // Must have breakout !link.href.includes('step=buy')) { // Must NOT be buy button bustButton = link; console.log(`JailBustSniper: Found bust button for ${userId}: ${link.href}`); break; } } // Strategy 2: Find any link with breakout in href if (!bustButton) { for (const link of row.querySelectorAll('a[href]')) { if (link.href.includes('breakout') && link.href.includes(`XID=${userId}`) && !link.href.includes('step=buy')) { // Exclude buy buttons bustButton = link; console.log(`JailBustSniper: Found bust button (strategy 2) for ${userId}: ${link.href}`); break; } } } // Log if no bust button found (for debugging) if (!bustButton) { console.log(`JailBustSniper: ⚠️ No bust button found for ${name} [${userId}]`); } // === CREATE PRISONER OBJECT === if (userId && name) { prisoners.push({ id: userId, userId, name, level: level || 0, sentence: sentence || 0, jailType, bustUrl: `https://www.torn.com/jailview.php?XID=${userId}&action=rescue&step=breakout`, // First step - shows confirmation bustButton: bustButton, // Store the actual DOM element pageNum: pageNum, // Which page this target is on pageUrl: pageNum === 1 ? 'https://www.torn.com/jailview.php#' : `https://www.torn.com/jailview.php#start=${(pageNum - 1) * 50}`, priorityScore: 0 }); successCount++; // Detailed logging for first 3 and any that fail to parse completely if (index < 3 || !level || !sentence) { const levelStr = level ? `Level ${level}` : 'Level N/A ❌'; const sentenceStr = sentence ? `${sentence} mins` : 'Sentence N/A ❌'; console.log(`JailBustSniper: Row ${index + 1}: ${name} [${userId}] - ${levelStr}, ${sentenceStr}, ${jailType} jail`); } } else { failCount++; } } catch (e) { console.error(`JailBustSniper: ❌ Error parsing row ${index}:`, e); failCount++; } }); // === SUMMARY === console.log(`JailBustSniper: ✅ PARSE COMPLETE - ${successCount} prisoners extracted, ${failCount} failed`); // Show stats on what was parsed const withLevel = prisoners.filter(p => p.level > 0).length; const withSentence = prisoners.filter(p => p.sentence > 0).length; const complete = prisoners.filter(p => p.level > 0 && p.sentence > 0).length; console.log(`JailBustSniper: 📊 Parse quality - Complete: ${complete}, With level: ${withLevel}, With sentence: ${withSentence}`); if (complete < prisoners.length * 0.5) { console.warn(`JailBustSniper: ⚠️ LOW QUALITY PARSE - Only ${Math.round(complete/prisoners.length*100)}% have complete data`); console.log(`JailBustSniper: 💡 Tip: Check browser console for detailed row parsing. The jail page structure may have changed.`); } return prisoners; } /** * Fetch jail page using hidden iframe */ async function fetchJailPageSingle(pageNum = 1) { return new Promise((resolve, reject) => { const iframe = document.createElement('iframe'); iframe.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1024px;height:768px;visibility:hidden;'; // Construct jail page URL with pagination // Jail page uses hash fragments: page 1 = #, page 2 = #start=50, page 3 = #start=100 const start = (pageNum - 1) * 50; // 50 prisoners per page const url = pageNum === 1 ? 'https://www.torn.com/jailview.php#' : `https://www.torn.com/jailview.php#start=${start}`; let resolved = false; let checkInterval; const cleanup = () => { clearInterval(checkInterval); iframe.remove(); }; const tryParse = () => { try { const doc = iframe.contentDocument || iframe.contentWindow?.document; if (!doc) return null; // Look for profile links - if found, page has rendered const profileLinks = doc.querySelectorAll('a[href*="profiles.php?XID="]'); if (profileLinks.length > 0) { return doc; } } catch (e) { // Cross-origin error shouldn't happen on same domain } return null; }; const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanup(); console.log(`JailBustSniper: Iframe timeout for page ${pageNum}`); reject(new Error('Iframe timeout')); } }, 30000); iframe.onload = () => { let attempts = 0; checkInterval = setInterval(() => { attempts++; const doc = tryParse(); if (doc) { if (!resolved) { resolved = true; clearTimeout(timeout); const prisoners = parseJailFromDoc(doc, pageNum); console.log(`JailBustSniper: Page ${pageNum} parsed ${prisoners.length} prisoners`); cleanup(); resolve(prisoners); } } else if (attempts > 50) { // Give up after 25 seconds if (!resolved) { resolved = true; clearTimeout(timeout); cleanup(); console.log(`JailBustSniper: Page ${pageNum} content never rendered`); reject(new Error('Content not rendered')); } } }, 500); }; iframe.onerror = () => { if (!resolved) { resolved = true; clearTimeout(timeout); cleanup(); reject(new Error('Iframe load error')); } }; iframe.src = url; document.body.appendChild(iframe); }); } /** * Fetch multiple jail pages */ async function fetchJailPages() { const MAX_PAGES = Math.min(settings.bustSniperPagesToScan || 2, 5); const allPrisoners = []; const seenIds = new Set(); for (let page = 1; page <= MAX_PAGES; page++) { console.log(`JailBustSniper: Loading page ${page}/${MAX_PAGES}...`); updateStatus(`Loading page ${page}/${MAX_PAGES}...`); try { const pagePrisoners = await fetchJailPageSingle(page); // Add unique prisoners let newCount = 0; for (const p of pagePrisoners) { if (!seenIds.has(p.id)) { seenIds.add(p.id); allPrisoners.push(p); newCount++; } } console.log(`JailBustSniper: Page ${page} found ${pagePrisoners.length} prisoners, ${newCount} new, total: ${allPrisoners.length}`); // Stop if no new prisoners (all duplicates = we've hit the end) if (newCount === 0 && pagePrisoners.length > 0) { console.log(`JailBustSniper: ⚠️ Page ${page} returned ${pagePrisoners.length} prisoners but all were duplicates`); console.log(`JailBustSniper: 💡 This means we've reached the last page (only ${page - 1} pages exist)`); break; } // If very few prisoners, might be at end if (pagePrisoners.length < 5) { console.log('JailBustSniper: Few results, stopping pagination'); break; } // Delay between pages if (page < MAX_PAGES) { await new Promise(r => setTimeout(r, PAGE_LOAD_DELAY)); } } catch (err) { console.error(`JailBustSniper: Error loading page ${page}:`, err); break; } } return allPrisoners; } /** * Filter prisoners by settings * ENHANCED with extensive debugging */ function filterPrisoners(prisoners) { console.log('JailBustSniper: 🔍 FILTER DEBUG - Starting filter process'); console.log('JailBustSniper: Filter settings:', { minLevel: settings.bustSniperMinLevel, maxLevel: settings.bustSniperMaxLevel, minSentence: settings.bustSniperMinSentence, maxSentence: settings.bustSniperMaxSentence, showNormal: settings.bustSniperShowNormalJail, showFederal: settings.bustSniperShowFederalJail }); const rejectionReasons = { levelTooLow: 0, levelTooHigh: 0, sentenceTooShort: 0, sentenceTooLong: 0, normalJailFiltered: 0, federalJailFiltered: 0, passed: 0 }; const filtered = prisoners.filter((p, index) => { let rejectReason = null; // Level filter if (p.level < settings.bustSniperMinLevel) { rejectReason = `level ${p.level} < min ${settings.bustSniperMinLevel}`; rejectionReasons.levelTooLow++; } else if (p.level > settings.bustSniperMaxLevel) { rejectReason = `level ${p.level} > max ${settings.bustSniperMaxLevel}`; rejectionReasons.levelTooHigh++; } // Sentence filter (in minutes) else if (p.sentence < settings.bustSniperMinSentence) { rejectReason = `sentence ${p.sentence}min < min ${settings.bustSniperMinSentence}min`; rejectionReasons.sentenceTooShort++; } else if (p.sentence > settings.bustSniperMaxSentence) { rejectReason = `sentence ${p.sentence}min > max ${settings.bustSniperMaxSentence}min`; rejectionReasons.sentenceTooLong++; } // Jail type filter else if (p.jailType === 'normal' && !settings.bustSniperShowNormalJail) { rejectReason = 'normal jail filtered out'; rejectionReasons.normalJailFiltered++; } else if (p.jailType === 'federal' && !settings.bustSniperShowFederalJail) { rejectReason = 'federal jail filtered out'; rejectionReasons.federalJailFiltered++; } if (rejectReason) { if (index < 5) { // Log first 5 rejections console.log(`JailBustSniper: ❌ REJECTED ${p.name} [${p.userId}]: ${rejectReason}`); } return false; } else { if (index < 5) { // Log first 5 passes console.log(`JailBustSniper: ✅ PASSED ${p.name} [${p.userId}] - Level: ${p.level}, Sentence: ${p.sentence}min, Jail: ${p.jailType}`); } rejectionReasons.passed++; return true; } }); console.log('JailBustSniper: 📊 FILTER RESULTS:', rejectionReasons); console.log(`JailBustSniper: ✅ ${filtered.length}/${prisoners.length} prisoners passed filters`); return filtered; } /** * Calculate priority score for a prisoner * Lower level + lower sentence = higher score (better target) */ function calculatePriorityScore(prisoner) { // Base score starts at 100 let score = 100; // Level component (0-40 points) // Lower level = higher score // Level 1 gets 40 points, level 100 gets 0 points const levelScore = ((100 - prisoner.level) / 100) * 40; score -= (40 - levelScore); // Sentence component (0-40 points) // Lower sentence = higher score // 0 minutes gets 40 points, 6000 minutes gets 0 points const maxSentence = 6000; // 100 hours const sentenceScore = ((maxSentence - Math.min(prisoner.sentence, maxSentence)) / maxSentence) * 40; score -= (40 - sentenceScore); // Jail type bonus (0-20 points) if (prisoner.jailType === 'federal') { score += 10; // Federal jail slightly more prestigious } else { score += 5; } return Math.max(0, Math.min(100, score)); } /** * Sort prisoners by selected strategy */ function sortPrisoners(prisoners) { const sorted = [...prisoners]; switch (settings.bustSniperSortBy) { case 'priority': // Best priority score first return sorted.sort((a, b) => b.priorityScore - a.priorityScore); case 'sentence': // Shortest sentence first return sorted.sort((a, b) => a.sentence - b.sentence); case 'level': // Lowest level first return sorted.sort((a, b) => a.level - b.level); case 'name': // Alphabetical return sorted.sort((a, b) => a.name.localeCompare(b.name)); default: return sorted; } } /** * Format time for display */ function formatTime(minutes) { if (minutes < 60) { return `${minutes}m`; } else { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } } /** * Main scan function */ async function scan() { if (!isEnabled || !isLeader) return; try { const now = Date.now(); // Fetch jail pages if (now - lastJailFetch > SCAN_INTERVAL) { updateStatus('Fetching jail data...'); const prisoners = await fetchJailPages(); cachedPrisoners = prisoners; lastJailFetch = now; scanIndex = 0; console.log(`JailBustSniper: Fetched ${prisoners.length} total prisoners`); } // Filter and score const filtered = filterPrisoners(cachedPrisoners); // Calculate priority scores filtered.forEach(p => { p.priorityScore = calculatePriorityScore(p); }); // Sort and limit const sorted = sortPrisoners(filtered); // Save current target IDs for comparison const previousTargetIds = new Set(targets.map(t => t.userId)); targets = sorted.slice(0, MAX_TARGETS); // Check for new targets const newTargetsInPanel = targets.filter(t => !previousTargetIds.has(t.userId)); if (newTargetsInPanel.length > 0 && settings.bustSniperSoundAlert) { console.log(`JailBustSniper: 🆕 New targets: ${newTargetsInPanel.map(t => t.name).join(', ')}`); playAlertSound(); } // Update status const statusMsg = `${targets.length} targets | ${filtered.length} total`; updateStatus(statusMsg); // Broadcast to other tabs broadcastTargets(); // Update UI renderPanel(); } catch (e) { console.error('JailBustSniper: Scan error', e); updateStatus('Error: ' + (e.message || 'Unknown')); } } /** * Play alert sound for new targets */ function playAlertSound() { try { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === 'suspended') { audioCtx.resume().then(() => playBeep()).catch(e => { console.log('JailBustSniper: Could not resume audio', e); }); } else { playBeep(); } } catch (e) { console.log('JailBustSniper: Audio not available', e); } } function playBeep() { if (!audioCtx) return; try { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); // Two-tone alert osc.frequency.value = 660; // E5 gain.gain.value = 0.3; osc.start(); osc.frequency.setValueAtTime(660, audioCtx.currentTime); osc.frequency.setValueAtTime(880, audioCtx.currentTime + 0.1); osc.frequency.setValueAtTime(660, audioCtx.currentTime + 0.2); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); osc.stop(audioCtx.currentTime + 0.3); console.log('JailBustSniper: 🔊 Alert sound played'); } catch (e) { console.log('JailBustSniper: Beep failed', e); } } function unlockAudio() { if (audioUnlocked) return; try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const buffer = audioCtx.createBuffer(1, 1, 22050); const source = audioCtx.createBufferSource(); source.buffer = buffer; source.connect(audioCtx.destination); source.start(0); audioUnlocked = true; console.log('JailBustSniper: Audio unlocked'); } catch (e) { console.log('JailBustSniper: Audio unlock failed', e); } } /** * Update status text in panel */ function updateStatus(text) { if (!panelEl) return; const status = panelEl.querySelector('.ff-bust-status'); if (status) { const roleIndicator = isLeader ? '●' : '●'; status.innerHTML = `${roleIndicator} ${text}`; } } /** * Render the panel UI */ function renderPanel() { if (!panelEl) { createPanel(); } const badge = panelEl.querySelector('.ff-bust-badge'); const list = panelEl.querySelector('.ff-bust-list'); // Update badge count if (badge) { badge.textContent = targets.length; badge.className = `ff-bust-badge${targets.length === 0 ? ' empty' : ''}`; } // Update list if (list) { if (targets.length === 0) { list.innerHTML = '
No targets found
'; } else { list.innerHTML = targets.map(t => { // Time urgency class let timeClass = ''; if (t.sentence <= 60) timeClass = 'urgent'; else if (t.sentence <= 180) timeClass = 'soon'; // Level difficulty badge let levelClass = 'easy'; if (t.level > 50) levelClass = 'hard'; else if (t.level > 25) levelClass = 'medium'; const jailIcon = t.jailType === 'federal' ? '⚖️' : '🔒'; // Status display let statusHTML = ''; if (t.status) { let statusColor = '#666'; let statusIcon = ''; switch(t.status) { case 'busting': statusColor = '#2196f3'; statusIcon = '⏳'; break; case 'not-found': statusColor = '#ff9800'; statusIcon = '👻'; break; case 'success': statusColor = '#4caf50'; statusIcon = '✅'; break; case 'error': statusColor = '#f44336'; statusIcon = '❌'; break; } statusHTML = `
${statusIcon} ${escapeHtml(t.statusMessage || '')}
`; } return `
${formatTime(t.sentence)} ${escapeHtml(t.name)} ${t.level} ${jailIcon} ${statusHTML}
`; }).join(''); } } } /** * Perform bust by clicking the stored TORN button element * Uses the bustButton we found during background scanning */ function performAutoBust(target, buttonEl) { if (!target) { console.error('JailBustSniper: No target provided'); return; } const { userId, userName, name, pageNum, pageUrl } = target; const displayName = userName || name; console.log(`JailBustSniper: 🎯 Attempting bust for ${displayName} [${userId}] on page ${pageNum || 1}`); // Update our button if (buttonEl) { buttonEl.disabled = true; buttonEl.textContent = '🔍'; buttonEl.style.opacity = '0.6'; } // CHECK: Are we on the correct page? const currentUrl = window.location.href; const onCorrectPage = pageUrl ? currentUrl.includes(pageUrl.split('#')[1] || 'jailview.php') : true; if (!onCorrectPage && pageUrl) { console.log(`JailBustSniper: ⚠️ Target is on page ${pageNum}, but we're viewing a different page`); console.log(`JailBustSniper: 🚀 Navigating to page ${pageNum} first...`); // Store bust intent in sessionStorage sessionStorage.setItem('ff-pending-bust', JSON.stringify({ userId: userId, userName: displayName, timestamp: Date.now() })); setTargetStatus(userId, 'busting', `Going to page ${pageNum}...`); showPanelMessage(`${displayName}: Navigating to page ${pageNum}...`, 'info'); // Navigate to target's page window.location.href = pageUrl; return; // Will resume on page load } // STEP 1: Find TORN's bust button on CURRENT page console.log(`JailBustSniper: Searching current page for bust button...`); let tornButton = null; // Search by XID in href - MUST have breakout, NOT buy const bustLinks = document.querySelectorAll('a[href*="action=rescue"], a.bust, a[class*="bust"]'); for (const link of bustLinks) { if (link.href && link.href.includes(`XID=${userId}`) && link.href.includes('rescue') && link.href.includes('breakout') && // Must be bust, not buy !link.href.includes('step=buy')) { tornButton = link; console.log(`JailBustSniper: ✅ Found bust button on current page via XID match`); console.log(`JailBustSniper: Button href: ${link.href}`); break; } } // Search in user's row if (!tornButton) { const listItems = document.querySelectorAll('li, div[class*="user"], div[class*="jail"], tr'); for (const item of listItems) { const profileLink = item.querySelector(`a[href*="XID=${userId}"]`); if (profileLink) { const bustLink = item.querySelector('a[href*="rescue"], a.bust, a[class*="bust"]'); if (bustLink && bustLink.href.includes('rescue') && bustLink.href.includes('breakout') && // Must be bust !bustLink.href.includes('step=buy')) { // Not buy tornButton = bustLink; console.log(`JailBustSniper: ✅ Found bust button in user's row`); console.log(`JailBustSniper: Button href: ${bustLink.href}`); break; } } } } // No button found if (!tornButton) { console.error(`JailBustSniper: ❌ Could not find TORN's bust button for user ${userId}`); // Check if we're supposedly on the correct page if (onCorrectPage && pageUrl) { // We're on the "correct" page but button not found // This likely means the page is stale (data changed since scan) console.log(`JailBustSniper: ⚠️ Button not found but we're on page ${pageNum}`); console.log(`JailBustSniper: 💡 Page data is likely stale, forcing refresh...`); // Update status setTargetStatus(userId, 'not-found', 'Refreshing page...'); showPanelMessage(`${displayName}: Refreshing page data...`, 'info'); // Store bust intent sessionStorage.setItem('ff-pending-bust', JSON.stringify({ userId: userId, userName: displayName, timestamp: Date.now() })); // Force refresh to get current data window.location.reload(); return; } // Not on correct page, or player not found (likely already busted) console.log(`JailBustSniper: 💡 Player not found - likely already busted by someone else`); // Update target status with clear message setTargetStatus(userId, 'not-found', 'Already busted'); showPanelMessage(`${displayName}: Player not found (likely already busted)`, 'warning'); if (buttonEl) { buttonEl.disabled = true; buttonEl.textContent = '👻'; buttonEl.title = 'Player not found (likely already busted)'; buttonEl.style.opacity = '0.5'; buttonEl.style.cursor = 'not-allowed'; } // Remove from targets after 3 seconds setTimeout(() => { targets = targets.filter(t => t.userId !== userId); renderPanel(); }, 3000); return; } // STEP 2: Click the button (triggers TORN's panel expansion) console.log(`JailBustSniper: 🎯 Clicking TORN's bust button to expand panel...`); if (buttonEl) { buttonEl.textContent = '⏳'; } setTargetStatus(userId, 'busting', 'Opening confirmation...'); showPanelMessage(`${displayName}: Opening bust confirmation...`, 'info'); try { tornButton.click(); console.log(`JailBustSniper: ✅ Bust button clicked - panel should expand`); } catch (e) { console.error(`JailBustSniper: ❌ Error clicking button:`, e); setTargetStatus(userId, 'error', 'Click failed'); showPanelMessage(`${displayName}: Failed to click button`, 'error'); if (buttonEl) { buttonEl.disabled = false; buttonEl.textContent = 'BUST'; buttonEl.style.background = ''; buttonEl.style.opacity = ''; } return; } // STEP 3: Wait for confirmation panel to appear, then click Yes console.log(`JailBustSniper: ⏰ Waiting for confirmation panel to appear...`); let attempts = 0; const maxAttempts = 40; // 20 seconds max const checkInterval = setInterval(() => { attempts++; // Look for Yes button (link with step=breakout1) const yesButtons = document.querySelectorAll('a[href*="step=breakout1"]'); for (const yesBtn of yesButtons) { if (yesBtn.href.includes(`XID=${userId}`)) { console.log(`JailBustSniper: ✅ Found Yes button in confirmation panel`); console.log(`JailBustSniper: Yes button href: ${yesBtn.href}`); clearInterval(checkInterval); // Click Yes button if (buttonEl) { buttonEl.textContent = '✓'; buttonEl.style.background = 'linear-gradient(135deg, #4caf50 0%, #388e3c 100%)'; } setTargetStatus(userId, 'busting', 'Clicking Yes...'); showPanelMessage(`${displayName}: Bust in progress...`, 'info'); setTimeout(() => { try { yesBtn.click(); console.log(`JailBustSniper: 🚀 Yes button clicked - bust in progress...`); setTargetStatus(userId, 'success', 'Bust complete!'); // Wait for result to appear setTimeout(() => { if (buttonEl) { buttonEl.disabled = false; buttonEl.textContent = 'BUST'; buttonEl.style.background = ''; buttonEl.style.opacity = ''; } // Refresh targets after bust if (isLeader) { setTimeout(() => scan(), 3000); } }, 5000); } catch (e) { console.error(`JailBustSniper: ❌ Error clicking Yes:`, e); if (buttonEl) { buttonEl.disabled = false; buttonEl.textContent = 'BUST'; buttonEl.style.background = ''; buttonEl.style.opacity = ''; } } }, 300); return; } } // Timeout if (attempts >= maxAttempts) { console.log(`JailBustSniper: ⚠️ Confirmation panel did not appear after ${maxAttempts/2} seconds`); console.log(`JailBustSniper: 💡 You may need to click Yes manually`); clearInterval(checkInterval); setTargetStatus(userId, 'error', 'Timeout - click manually'); showPanelMessage(`${displayName}: Confirmation timeout - you may need to click Yes manually`, 'warning'); if (buttonEl) { buttonEl.disabled = false; buttonEl.textContent = 'BUST'; buttonEl.style.background = ''; buttonEl.style.opacity = ''; } } }, 500); } /** * Show bust notification overlay */ /** * Show message in panel (replaces popup notifications) */ function showPanelMessage(message, type = 'info') { if (!panelEl) return; const messageEl = panelEl.querySelector('.ff-bust-message'); if (!messageEl) return; // Color and icon based on type let bgColor, icon; switch(type) { case 'success': bgColor = 'linear-gradient(135deg, #4caf50 0%, #388e3c 100%)'; icon = '✅'; break; case 'error': bgColor = 'linear-gradient(135deg, #f44336 0%, #c62828 100%)'; icon = '❌'; break; case 'warning': bgColor = 'linear-gradient(135deg, #ff9800 0%, #f57c00 100%)'; icon = '⚠️'; break; default: // info bgColor = 'linear-gradient(135deg, #2196f3 0%, #1976d2 100%)'; icon = 'ℹ️'; } messageEl.style.background = bgColor; messageEl.style.color = 'white'; messageEl.style.display = 'block'; messageEl.innerHTML = `${icon} ${message}`; // Auto-hide after 5 seconds setTimeout(() => { messageEl.style.display = 'none'; }, 5000); } /** * Set status for specific target */ function setTargetStatus(userId, status, message) { const target = targets.find(t => t.userId === userId); if (target) { target.status = status; target.statusMessage = message; renderPanel(); // Update UI } } /** * OLD: Show bust notification (kept for compatibility but replaced) */ function showBustNotification(userName, success, message) { // Deprecated - now uses showPanelMessage const type = success ? 'info' : 'error'; showPanelMessage(`${userName}: ${message}`, type); } /** * Create the panel element */ function createPanel() { // Duplicate prevention if (panelCreated || document.querySelector('.ff-bust-panel')) { console.log('JailBustSniper: Panel already exists'); if (!panelEl) { panelEl = document.querySelector('.ff-bust-panel'); } return; } // Load saved collapsed state try { const savedCollapsed = localStorage.getItem(STORAGE_KEY_COLLAPSED); if (savedCollapsed !== null) { isCollapsed = JSON.parse(savedCollapsed); } } catch (e) {} panelEl = document.createElement('div'); panelEl.className = 'ff-bust-panel' + (isCollapsed ? ' collapsed' : '') + (!isLeader ? ' follower' : ''); panelEl.setAttribute('data-tab-id', TAB_ID); panelEl.innerHTML = `
🚔 Bust Sniper 0 📡 ${isCollapsed ? '▶' : '▼'}
Initializing...
`; // Toggle collapse const toggle = panelEl.querySelector('.ff-bust-toggle'); toggle.addEventListener('click', (e) => { e.stopPropagation(); unlockAudio(); isCollapsed = !isCollapsed; panelEl.classList.toggle('collapsed', isCollapsed); toggle.textContent = isCollapsed ? '▶' : '▼'; try { localStorage.setItem(STORAGE_KEY_COLLAPSED, JSON.stringify(isCollapsed)); } catch (err) {} }); // Unlock audio on panel click panelEl.addEventListener('click', unlockAudio, { once: true }); // Leader button const leaderBtn = panelEl.querySelector('.ff-bust-leader-btn'); if (leaderBtn) { leaderBtn.addEventListener('click', (e) => { e.stopPropagation(); if (!isLeader) { forceLeadership(); } }); } // Event delegation for BUST buttons panelEl.addEventListener('click', (e) => { const bustBtn = e.target.closest('.ff-bust-btn'); if (bustBtn) { e.preventDefault(); e.stopPropagation(); const bustUrl = bustBtn.dataset.bustUrl; const userName = bustBtn.dataset.userName; const userId = bustBtn.dataset.userId; console.log(`JailBustSniper: Attempting auto-bust on ${userName} [${userId}]`); // Find the full target object from targets array const target = targets.find(t => t.userId === userId); // Disable button during bust attempt bustBtn.disabled = true; bustBtn.textContent = '...'; // Execute auto-bust with full target object (includes stored bustButton) performAutoBust(target, bustBtn); } }); document.body.appendChild(panelEl); panelCreated = true; // Apply saved position const pos = getSavedPosition(); applyPosition(pos.x, pos.y); // Setup drag setupDragListeners(); // Update role display updatePanelRole(); // Check for stored bust results from previous page load checkStoredBustResult(); // Unlock audio on document interaction document.addEventListener('click', unlockAudio, { once: true }); document.addEventListener('touchstart', unlockAudio, { once: true }); } /** * Check for and display stored bust result from previous page */ function checkStoredBustResult() { try { const stored = sessionStorage.getItem('ff-bust-result'); if (!stored) return; const result = JSON.parse(stored); const { userName, status, message, timestamp } = result; // Check age (expire after 30 seconds) if (Date.now() - timestamp > 30000) { sessionStorage.removeItem('ff-bust-result'); return; } // Clear it immediately to prevent repeated display sessionStorage.removeItem('ff-bust-result'); console.log(`JailBustSniper: Displaying stored bust result for ${userName}`); // Show the stored message let messageType = 'info'; switch(status) { case 'not-found': messageType = 'warning'; break; case 'success': messageType = 'success'; break; case 'error': messageType = 'error'; break; } showPanelMessage(`${userName}: ${message}`, messageType); // Keep message visible longer for important info if (status === 'not-found' || status === 'error') { setTimeout(() => { const messageEl = panelEl?.querySelector('.ff-bust-message'); if (messageEl && messageEl.textContent.includes(userName)) { messageEl.style.display = 'none'; } }, 10000); // 10 seconds for errors/warnings } } catch (e) { console.error('JailBustSniper: Error checking stored bust result:', e); sessionStorage.removeItem('ff-bust-result'); } } /** * Get saved position from localStorage */ function getSavedPosition() { try { const saved = localStorage.getItem(STORAGE_KEY_POSITION); if (saved) { return JSON.parse(saved); } } catch (e) {} return { x: 20, y: 320 }; // Default position (below bounty sniper) } /** * Apply position to panel */ function applyPosition(x, y) { if (!panelEl) return; panelEl.style.left = x + 'px'; panelEl.style.top = y + 'px'; } /** * Setup drag listeners */ function setupDragListeners() { if (!panelEl) return; const header = panelEl.querySelector('.ff-bust-header'); let isDragging = false; let startX, startY, initialX, initialY; const onMouseDown = (e) => { // Don't drag if clicking toggle or buttons if (e.target.closest('.ff-bust-toggle') || e.target.closest('.ff-bust-leader-btn')) { return; } isDragging = true; startX = e.clientX || e.touches[0].clientX; startY = e.clientY || e.touches[0].clientY; const rect = panelEl.getBoundingClientRect(); initialX = rect.left; initialY = rect.top; e.preventDefault(); }; const onMouseMove = (e) => { if (!isDragging) return; const clientX = e.clientX || e.touches[0].clientX; const clientY = e.clientY || e.touches[0].clientY; const deltaX = clientX - startX; const deltaY = clientY - startY; applyPosition(initialX + deltaX, initialY + deltaY); }; const onMouseUp = () => { if (isDragging) { isDragging = false; const rect = panelEl.getBoundingClientRect(); const pos = { x: rect.left, y: rect.top }; try { localStorage.setItem(STORAGE_KEY_POSITION, JSON.stringify(pos)); } catch (e) {} } }; header.addEventListener('mousedown', onMouseDown); header.addEventListener('touchstart', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('touchmove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.addEventListener('touchend', onMouseUp); } /** * Start the bust sniper */ function start() { if (!settings.bustSniperEnabled) return; console.log(`JailBustSniper: Starting (Tab: ${TAB_ID})`); isEnabled = true; // Election and coordination electLeader(); listenForTargets(); // Create panel createPanel(); // Start scanning if leader if (isLeader) { scan(); // Initial scan scanInterval = setInterval(scan, SCAN_INTERVAL); // Heartbeat setInterval(saveLeaderInfo, LEADER_HEARTBEAT_INTERVAL); } updateStatus('Ready'); } /** * Stop the bust sniper */ function stop() { isEnabled = false; if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } if (panelEl) { panelEl.remove(); panelEl = null; panelCreated = false; } console.log('JailBustSniper: Stopped'); } // Cleanup on tab close window.addEventListener('beforeunload', () => { if (isLeader) { localStorage.removeItem(STORAGE_KEY_LEADER); } }); /** * Force this tab to become leader (used after navigation) */ function forceLeader() { console.log('JailBustSniper: 👑 Forcing leader mode...'); isLeader = true; saveLeaderInfo(); // Use existing function // Update UI if (panelEl) { updatePanelRole(); } // Start scanning if enabled if (isEnabled && !scanInterval) { scanInterval = setInterval(() => scan(), SCAN_INTERVAL); scan(); // Immediate scan } console.log('JailBustSniper: ✅ Now in leader mode'); } // Public API return { start, stop, scan, getTargets: () => targets, isRunning: () => isEnabled, isLeader: () => isLeader, forceLeader // NEW: For forcing leader after navigation }; })(); // ============================================================================ // CONSOLIDATED OBSERVER MANAGER // ============================================================================ const ObserverManager = (() => { let observer = null; let debounceTimer = null; const seenAvailCells = new WeakSet(); const seenMarketRows = new WeakSet(); function processDOM() { // Profile injection injectProfile(); // List items (faction, user lists) document.querySelectorAll('div[class*="honorWrap"]').forEach(injectListItem); // Bounty page injection if (location.href.includes('bounties.php')) { // Find all list items that contain profile links document.querySelectorAll('li').forEach(li => { const profileLink = li.querySelector('a[href*="profiles.php?XID="]'); if (profileLink && li.getAttribute('data-ff-bounty') !== '1') { injectBountyItem(li); } }); // Also check for any profile links in table-like structures document.querySelectorAll('a[href*="profiles.php?XID="]').forEach(link => { // Find the row container (li, tr, or div) const row = link.closest('li') || link.closest('tr') || link.closest('[class*="row"]'); if (row && row.getAttribute('data-ff-bounty') !== '1') { injectBountyItem(row); } }); } // Market attack buttons (seller list) document.querySelectorAll('[class*="sellerListWrapper"] ul a[href*="profiles.php?XID="]') .forEach(injectMarketAttackButton); // Market rows (PDA/PC) document.querySelectorAll('[class*="rowWrapper"]').forEach(row => { if (!seenMarketRows.has(row)) { seenMarketRows.add(row); row.style.transition = 'background 2.5s cubic-bezier(.4,0,.2,1)'; row.style.background = '#ff3e30'; setTimeout(() => row.style.background = '', 1200); } const profileLink = row.querySelector('a[href*="profiles.php?XID="]'); if (profileLink) injectMarketAttackButton(profileLink); }); // "Available" cell highlighting (optimized selector) document.querySelectorAll('div').forEach(el => { if (el.childNodes.length === 1 && el.textContent.match(/^\d+\s*available$/)) { if (!seenAvailCells.has(el)) { seenAvailCells.add(el); el.style.transition = 'background 3s cubic-bezier(.4,0,.2,1)'; el.style.background = '#ff0000'; setTimeout(() => el.style.background = '', 1200); } } }); // RSI+ panel mount fallback if (!document.getElementById('ff-exp-panel')) { const badge = document.querySelector('span.ff-score-badge'); if (badge) { const h = badge.closest('h4'); if (h && window.__FF_ME_STATS_OBJ && window.__FF_LAST_PROFILE_OBJ) { buildRSIPlusPanel(h, window.__FF_ME_STATS_OBJ, window.__FF_LAST_PROFILE_OBJ); } } } // Update countdown chips updateCountdowns(); // Prune orphaned overlays pruneOrphans(); // Anti-overlap adjustments adjustOverlaps(); } function updateCountdowns() { document.querySelectorAll('.ff-countdown-chip').forEach(chip => { const until = parseInt(chip.dataset.ffUntil || '0', 10); if (!until) { chip.textContent = ''; return; } const remaining = Math.max(0, until - Date.now()); const s = Math.floor(remaining / 1000); const hh = String(Math.floor(s / 3600)).padStart(2, '0'); const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0'); const ss = String(s % 60).padStart(2, '0'); chip.textContent = `${hh}:${mm}:${ss}`; }); } function pruneOrphans() { document.querySelectorAll('.honor-text-wrap, div[class*="honorWrap"]').forEach(wrap => { if (!wrap.querySelector('.ff-travel-icon')) { wrap.querySelectorAll('.ff-loc-badge').forEach(n => n.remove()); } if (!wrap.querySelector('.ff-hospital-icon')) { wrap.querySelectorAll('.ff-countdown-chip').forEach(n => n.remove()); } }); } function adjustOverlaps() { document.querySelectorAll('.honor-text-wrap, div[class*="honorWrap"]').forEach(wrap => { const wRect = wrap.getBoundingClientRect(); if (!wRect.width) return; const cell = wrap.closest('.table-cell') || wrap.closest('td,th') || wrap.parentElement; if (!cell?.parentElement) return; const barriers = []; Array.from(cell.parentElement.children).forEach(sib => { if (sib === cell) return; const r = sib.getBoundingClientRect(); if (r.width && r.left <= wRect.right && r.left >= wRect.left) { barriers.push(r.left); } }); let shift = 0; if (barriers.length) { const inside = barriers.filter(x => x < wRect.right); const target = inside.length ? Math.max(...inside) : null; if (target !== null) shift = Math.ceil(wRect.right - target) + 6; } wrap.querySelectorAll('.ff-travel-icon, .ff-hospital-icon, .ff-loc-badge, .ff-countdown-chip') .forEach(el => { el.classList.add('ff-anti-overlap'); el.style.transform = shift ? `translateX(${-shift}px)` : ''; }); }); } function debouncedProcess() { if (debounceTimer) return; debounceTimer = setTimeout(() => { debounceTimer = null; processDOM(); }, 100); } return { start() { processDOM(); observer = new MutationObserver(debouncedProcess); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener('popstate', processDOM); // Countdown timer setInterval(updateCountdowns, 1000); // Setup fight capture on attack pages FightCapture.setupAttackPageObserver(); }, stop() { observer?.disconnect(); observer = null; } }; })(); // ============================================================================ // SETTINGS UI WITH STATS TAB // ============================================================================ function createSettingsUI() { const fab = document.createElement('div'); fab.className = 'ff-fab'; fab.textContent = '⚙️'; const backdrop = document.createElement('div'); backdrop.className = 'ff-modal-backdrop'; const modal = document.createElement('div'); modal.className = 'ff-modal'; modal.innerHTML = `

ATK Scouter Settings v${VERSION}

⚙️ Settings
🎯 Sniper
🚔 Bust
📊 Stats
🔑 API Key

⚡ RSI Cache System
Persistent caching reduces API calls by 90%+




Level Range

Sentence Range (hours)

💡 Enter hours (e.g., 5 for 5 hours). Max 100 hours.


💡 Tip: Lower level + shorter sentence = easier busts. The system prioritizes targets automatically.
Loading stats...
`; document.body.append(fab, backdrop, modal); // Tab switching modal.querySelectorAll('.ff-tab').forEach(tab => { tab.addEventListener('click', () => { modal.querySelectorAll('.ff-tab, .ff-tab-content').forEach(el => el.classList.remove('active')); tab.classList.add('active'); modal.querySelector('#tab-' + tab.dataset.tab)?.classList.add('active'); // Load stats when stats tab is shown if (tab.dataset.tab === 'stats') { loadStatsTab(); } }); }); const closeModal = () => { modal.style.display = 'none'; backdrop.style.display = 'none'; }; fab.addEventListener('click', () => { backdrop.style.display = 'block'; modal.style.display = 'block'; }); backdrop.addEventListener('click', closeModal); modal.querySelector('#ff-cancel')?.addEventListener('click', closeModal); modal.querySelector('#ff-clear-key')?.addEventListener('click', () => { Env.deleteValue('api_key'); API_KEY = ''; const keyInput = modal.querySelector('#ff-key'); if (keyInput) keyInput.value = ''; alert('API key cleared'); }); modal.querySelector('#ff-save')?.addEventListener('click', () => { const keyInput = modal.querySelector('#ff-key'); const newKey = keyInput?.value.trim(); // Only save if it doesn't look like the masked key if (newKey && !newKey.includes('••')) { Env.setValue('api_key', newKey); API_KEY = newKey; } const v1 = parseFloat(modal.querySelector('#ff-th1')?.value); const v2 = parseFloat(modal.querySelector('#ff-th2')?.value); const v3 = parseFloat(modal.querySelector('#ff-lw')?.value); const v4 = parseFloat(modal.querySelector('#ff-dw')?.value); if (!isNaN(v1)) Env.setValue('threshold_lowHigh', v1); if (!isNaN(v2)) Env.setValue('threshold_highMed', v2); if (!isNaN(v3)) Env.setValue('lifeWeight', v3); if (!isNaN(v4)) Env.setValue('drugWeight', v4); // Save sniper settings Env.setValue('sniperEnabled', modal.querySelector('#ff-sniper-enabled')?.checked || false); Env.setValue('sniperMinReward', parseInt(modal.querySelector('#ff-sniper-minreward')?.value) || DEFAULTS.sniperMinReward); Env.setValue('sniperMaxLevel', parseInt(modal.querySelector('#ff-sniper-maxlevel')?.value) || DEFAULTS.sniperMaxLevel); Env.setValue('sniperRsiMin', parseInt(modal.querySelector('#ff-sniper-rsimin')?.value) || DEFAULTS.sniperRsiMin); Env.setValue('sniperRsiMax', parseInt(modal.querySelector('#ff-sniper-rsimax')?.value) || DEFAULTS.sniperRsiMax); Env.setValue('sniperShowOkay', modal.querySelector('#ff-sniper-showokay')?.checked || false); Env.setValue('sniperShowHospital', modal.querySelector('#ff-sniper-showhospital')?.checked || false); Env.setValue('sniperShowTraveling', modal.querySelector('#ff-sniper-showtraveling')?.checked || false); Env.setValue('sniperHospitalMins', parseInt(modal.querySelector('#ff-sniper-hospitalmins')?.value) || DEFAULTS.sniperHospitalMins); Env.setValue('sniperPagesToScan', Math.min(20, Math.max(1, parseInt(modal.querySelector('#ff-sniper-pagestoscan')?.value) || DEFAULTS.sniperPagesToScan))); Env.setValue('sniperApiBudget', parseInt(modal.querySelector('#ff-sniper-apibudget')?.value) || DEFAULTS.sniperApiBudget); Env.setValue('sniperSortBy', modal.querySelector('#ff-sniper-sortby')?.value || DEFAULTS.sniperSortBy); Env.setValue('sniperSoundAlert', modal.querySelector('#ff-sniper-soundalert')?.checked || false); // Save bust sniper settings Env.setValue('bustSniperEnabled', modal.querySelector('#ff-bust-sniper-enabled')?.checked || false); Env.setValue('bustSniperMinLevel', parseInt(modal.querySelector('#ff-bust-minlevel')?.value) || DEFAULTS.bustSniperMinLevel); Env.setValue('bustSniperMaxLevel', parseInt(modal.querySelector('#ff-bust-maxlevel')?.value) || DEFAULTS.bustSniperMaxLevel); // Convert hours to minutes for storage Env.setValue('bustSniperMinSentence', (parseInt(modal.querySelector('#ff-bust-minsentence')?.value) || 0) * 60); Env.setValue('bustSniperMaxSentence', (parseInt(modal.querySelector('#ff-bust-maxsentence')?.value) || 100) * 60); Env.setValue('bustSniperPagesToScan', parseInt(modal.querySelector('#ff-bust-pagestoscan')?.value) || DEFAULTS.bustSniperPagesToScan); Env.setValue('bustSniperSoundAlert', modal.querySelector('#ff-bust-soundalert')?.checked || false); Env.setValue('bustSniperSortBy', modal.querySelector('#ff-bust-sortby')?.value || DEFAULTS.bustSniperSortBy); Env.setValue('bustSniperFetchInterval', parseInt(modal.querySelector('#ff-bust-interval')?.value) || DEFAULTS.bustSniperFetchInterval); Env.setValue('bustSniperShowNormalJail', modal.querySelector('#ff-bust-show-normal')?.checked !== false); Env.setValue('bustSniperShowFederalJail', modal.querySelector('#ff-bust-show-federal')?.checked !== false); Env.setValue('bustSniperMaxTargets', Math.min(10, Math.max(1, parseInt(modal.querySelector('#ff-bust-maxtargets')?.value) || DEFAULTS.bustSniperMaxTargets))); // Save RSI cache settings Env.setValue('rsiCacheEnabled', modal.querySelector('#ff-rsi-cache-enabled')?.checked !== false); Env.setValue('rsiCacheShowConfidence', modal.querySelector('#ff-rsi-cache-show-confidence')?.checked !== false); Env.setValue('rsiCacheBackgroundRefresh', modal.querySelector('#ff-rsi-cache-background-refresh')?.checked !== false); console.log('Settings saved, preparing to reload...'); // CRITICAL: Guard against multiple reload attempts if (_reloadInProgress) { console.log('Reload already in progress, ignoring duplicate save click'); return; } _reloadInProgress = true; // CRITICAL: Close modal first to prevent interference with navigation closeModal(); // Show loading indicator const loadingDiv = document.createElement('div'); loadingDiv.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.9);color:#fff;padding:20px 40px;border-radius:8px;z-index:999999;font-size:16px;'; loadingDiv.textContent = '💾 Saving settings...'; document.body.appendChild(loadingDiv); // Use setTimeout to allow modal close to complete, then trigger reload // This is critical for mobile browsers - they need the event loop to complete setTimeout(() => { loadingDiv.textContent = '🔄 Reloading page...'; console.log('Attempting reload...'); // Try reload methods sequentially with delays // Method 1: Direct href assignment (most compatible) console.log('Trying Method 1: location.href assignment'); window.location.href = window.location.href; // Fallback: If still here after 500ms, try harder reload setTimeout(() => { console.log('Trying Method 2: location.reload()'); window.location.reload(); }, 500); // Final fallback: If still here after 1.5 seconds, show manual refresh message setTimeout(() => { loadingDiv.innerHTML = '⚠️ Auto-reload failed
Please refresh manually (pull down or F5)'; setTimeout(() => { loadingDiv.remove(); _reloadInProgress = false; // Reset guard }, 5000); }, 1500); }, 100); // 100ms delay allows modal close to complete }); // Cache stats button handlers modal.querySelector('#ff-refresh-cache-stats')?.addEventListener('click', async () => { const btn = modal.querySelector('#ff-refresh-cache-stats'); const originalText = btn.textContent; btn.textContent = 'Loading...'; btn.disabled = true; try { const stats = await RSICacheManager.getStats(); const container = modal.querySelector('#ff-cache-stats-container'); const cacheSizeMB = ((stats.totalPlayers * 2) / 1000).toFixed(2); container.innerHTML = `
Players Cached: ${stats.totalPlayers.toLocaleString()}
Cache Hit Rate: ${stats.cacheHitRate}
API Calls Saved: ${stats.apiCallsSaved.toLocaleString()}
Oldest Entry: ${stats.oldestEntry}
Est. Size: ~${cacheSizeMB} MB
`; } catch (e) { console.error('Error loading cache stats:', e); alert('Error loading cache stats. See console.'); } finally { btn.textContent = originalText; btn.disabled = false; } }); modal.querySelector('#ff-clear-cache-btn')?.addEventListener('click', async () => { if (!confirm('Clear ALL cached RSI data? This cannot be undone.')) return; const btn = modal.querySelector('#ff-clear-cache-btn'); const originalText = btn.textContent; btn.textContent = 'Clearing...'; btn.disabled = true; try { await RSICacheManager.clearAll(); alert('Cache cleared successfully!'); modal.querySelector('#ff-refresh-cache-stats')?.click(); } catch (e) { console.error('Error clearing cache:', e); alert('Error clearing cache. See console.'); } finally { btn.textContent = originalText; btn.disabled = false; } }); // Auto-load cache stats when modal opens if (settings.rsiCacheEnabled) { setTimeout(() => modal.querySelector('#ff-refresh-cache-stats')?.click(), 100); } // Auto-open settings if no API key if (!API_KEY) { fab.click(); modal.querySelector('[data-tab=apikey]')?.click(); } } /** * Loads the stats tab content */ async function loadStatsTab() { const container = document.getElementById('ff-stats-content'); if (!container) return; try { const isAvailable = await FightDB.isAvailable(); if (!isAvailable) { container.innerHTML = `

📵 IndexedDB not available

Fight logging requires IndexedDB support.

`; return; } const [overall, buckets, recent] = await Promise.all([ FightDB.getOverallStats(), FightDB.getStatsByRsiBucket(), FightDB.getRecentFights(20) ]); if (overall.total === 0) { container.innerHTML = `

📭 No fights logged yet

Fight outcomes are automatically captured when you attack players.

View a profile, then attack — the result will be logged.

`; return; } const winRateClass = overall.winRate >= 60 ? '' : overall.winRate >= 40 ? 'warning' : 'danger'; container.innerHTML = `
${overall.total} Total Fights
${overall.winRate.toFixed(1)}% Win Rate
${overall.wins} Wins
${overall.losses} Losses

Win Rate by RSI Bucket

${Object.entries(buckets).map(([range, data]) => { const wr = data.total > 0 ? ((data.wins / data.total) * 100).toFixed(1) : '—'; const wrClass = data.total === 0 ? '' : (data.wins / data.total >= 0.6 ? 'ff-fight-win' : data.wins / data.total < 0.4 ? 'ff-fight-loss' : ''); return ` `; }).join('')}
RSI Range Fights Wins Win Rate
${escapeHtml(range)}% ${data.total} ${data.wins} ${wr}%

Recent Fights

${recent.length === 0 ? '
No recent fights
' : recent.map(f => { const outcomeClass = f.outcome === 'win' ? 'ff-fight-win' : f.outcome === 'loss' ? 'ff-fight-loss' : 'ff-fight-other'; const rsiDisplay = f.rsiAdjusted ? f.rsiAdjusted.toFixed(1) + '%' : '—'; const timeAgo = formatTimeAgo(f.timestamp || f.capturedAt); return `
${escapeHtml(f.opponentName || 'Unknown')} [${f.opponentId || '?'}] RSI: ${rsiDisplay} ${f.outcome?.toUpperCase() || '?'} ${timeAgo}
`; }).join('') }
`; // Wire up buttons document.getElementById('ff-export-data')?.addEventListener('click', async () => { try { const data = await FightDB.exportData(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `atk-scouter-fights-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); showNotification('Data exported!'); } catch (e) { showNotification('Export failed: ' + e.message, true); } }); document.getElementById('ff-clear-data')?.addEventListener('click', async () => { if (!confirm('Are you sure you want to delete ALL fight data? This cannot be undone.')) return; try { await FightDB.clearAllData(); showNotification('All data cleared'); loadStatsTab(); // Reload } catch (e) { showNotification('Clear failed: ' + e.message, true); } }); } catch (e) { console.error('Error loading stats:', e); container.innerHTML = `

❌ Error loading stats

${escapeHtml(e.message)}

`; } } /** * Formats timestamp as relative time */ function formatTimeAgo(timestamp) { if (!timestamp) return ''; const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'; if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'; return Math.floor(seconds / 86400) + 'd ago'; } // ============================================================================ // INITIALIZATION // ============================================================================ /** * Auto-sync learning model from IndexedDB fights * Runs once on startup if learning model is empty but fights exist */ async function autoSyncLearningModel() { try { const hist = Env.getValue('ff_lrsi_hist', []) || []; // Only auto-sync if learning model is empty if (hist.length > 0) return; const fights = await FightDB.getAllFights(); if (!fights || fights.length < 3) return; // Need at least 3 fights // Convert fights to learning format const samples = []; for (const fight of fights) { const rsi = fight.rsiAdjusted || fight.rsiRaw; if (rsi === null || rsi === undefined) continue; if (fight.outcome !== 'win' && fight.outcome !== 'loss') continue; samples.push({ x: rsi - 100, y: fight.outcome === 'win' ? 1 : 0, ts: fight.capturedAt || Date.now() }); } if (samples.length < 3) return; // Perform logistic regression fit let t0 = 0, t1 = 0.12; const lr = 0.05; for (let iter = 0; iter < 100; iter++) { let g0 = 0, g1 = 0; for (const s of samples) { const p = 1 / (1 + Math.exp(-(t0 + t1 * s.x))); const err = s.y - p; g0 += err; g1 += err * s.x; } t0 += lr * g0 / samples.length; t1 += lr * g1 / samples.length; } t1 = Math.max(0.01, Math.min(0.5, t1)); // Save to learning model Env.setValue('ff_lrsi_theta0', t0); Env.setValue('ff_lrsi_theta1', t1); Env.setValue('ff_lrsi_hist', samples); Env.setValue('ff_lrsi_meta', { n: samples.length, ts: Date.now() }); console.log(`ATK Scouter: Auto-synced learning model from ${samples.length} fights (θ0=${t0.toFixed(3)}, θ1=${t1.toFixed(3)})`); } catch (e) { console.error('ATK Scouter: Auto-sync failed', e); } } /** * Auto-Yes Clicker for Bust Confirmation * Runs on step=breakout pages and automatically clicks the Yes button */ function setupAutoYesClicker() { // Check if we're on a bust confirmation page const url = window.location.href; if (!url.includes('action=rescue') || !url.includes('step=breakout')) { return; // Not a bust page } // Don't run on step=breakout1 (already past confirmation) if (url.includes('step=breakout1')) { return; } console.log('JailBustSniper: 🎯 On bust confirmation page, looking for Yes button...'); // Function to find and click Yes button function findAndClickYes() { // Strategy 1: Find link with step=breakout1 const links = document.querySelectorAll('a[href*="step=breakout1"]'); if (links.length > 0) { console.log('JailBustSniper: ✅ Found Yes button (breakout1 link)'); links[0].click(); return true; } // Strategy 2: Find button/link with "Yes" text near "break out" context const allButtons = document.querySelectorAll('button, a, input[type="button"], input[type="submit"]'); for (const btn of allButtons) { const text = (btn.textContent || btn.value || '').trim().toLowerCase(); if (text === 'yes' || text === 'confirm' || text === 'proceed') { // Check if it's in bust context const parent = btn.closest('div, p, section, article'); if (parent) { const parentText = parent.textContent.toLowerCase(); if (parentText.includes('break') || parentText.includes('bust') || parentText.includes('jail')) { console.log('JailBustSniper: ✅ Found Yes button by text:', text); btn.click(); return true; } } } } // Strategy 3: Find any button with href containing breakout1 const allLinks = document.querySelectorAll('a[href]'); for (const link of allLinks) { if (link.href.includes('breakout1') && link.href.includes('rescue')) { console.log('JailBustSniper: ✅ Found Yes button (any breakout1 link)'); link.click(); return true; } } return false; } // Try immediately if (findAndClickYes()) { console.log('JailBustSniper: 🚀 Yes button clicked, proceeding to bust...'); return; } // Wait for page to fully load, then try again let attempts = 0; const maxAttempts = 20; // 10 seconds max const interval = setInterval(() => { attempts++; if (findAndClickYes()) { console.log('JailBustSniper: 🚀 Yes button clicked, proceeding to bust...'); clearInterval(interval); return; } if (attempts >= maxAttempts) { console.log('JailBustSniper: ⚠️ Could not find Yes button after', maxAttempts, 'attempts'); console.log('JailBustSniper: 💡 You may need to click Yes manually'); clearInterval(interval); } }, 500); } /** * Check for pending bust and auto-execute after page navigation */ function checkPendingBust() { // Only run on jail pages if (!window.location.href.includes('jailview.php')) { return; } const pendingBust = sessionStorage.getItem('ff-pending-bust'); if (!pendingBust) { return; } try { const data = JSON.parse(pendingBust); const { userId, userName, timestamp } = data; // Check age (expire after 1 minute) if (Date.now() - timestamp > 60000) { console.log('JailBustSniper: Pending bust expired, clearing'); sessionStorage.removeItem('ff-pending-bust'); return; } // Clear it immediately sessionStorage.removeItem('ff-pending-bust'); console.log(`JailBustSniper: 🎯 Resuming pending bust for ${userName} [${userId}]`); // CRITICAL: Force this tab to become leader after navigation // Otherwise it might go into satellite mode and stop working if (typeof JailBustSniper !== 'undefined' && JailBustSniper.forceLeader) { console.log(`JailBustSniper: 👑 Forcing leader mode after navigation`); JailBustSniper.forceLeader(); } // Wait for page to fully load, then find and click button setTimeout(() => { console.log(`JailBustSniper: Looking for bust button for ${userName}...`); // Find button on this page const bustLinks = document.querySelectorAll('a[href*="action=rescue"], a.bust, a[class*="bust"]'); let tornButton = null; for (const link of bustLinks) { if (link.href && link.href.includes(`XID=${userId}`) && link.href.includes('rescue') && link.href.includes('breakout') && !link.href.includes('step=buy')) { tornButton = link; console.log(`JailBustSniper: ✅ Found button for resumed bust`); break; } } if (!tornButton) { console.log(`JailBustSniper: ❌ Could not find button for ${userName} on this page`); console.log(`JailBustSniper: 💡 Player likely already busted by someone else`); // Store result so panel can display it after initialization sessionStorage.setItem('ff-bust-result', JSON.stringify({ userName: userName, status: 'not-found', message: 'Player not found (likely already busted)', timestamp: Date.now() })); console.log(`JailBustSniper: 📝 Stored bust result for display`); return; } // Click it to expand panel console.log(`JailBustSniper: 🎯 Clicking bust button to expand panel...`); tornButton.click(); // Wait for panel, then click Yes setTimeout(() => { let attempts = 0; const checkInterval = setInterval(() => { attempts++; const yesButtons = document.querySelectorAll('a[href*="step=breakout1"]'); for (const yesBtn of yesButtons) { if (yesBtn.href.includes(`XID=${userId}`)) { console.log(`JailBustSniper: ✅ Found Yes button, clicking...`); clearInterval(checkInterval); yesBtn.click(); console.log(`JailBustSniper: 🚀 Resumed bust complete!`); return; } } if (attempts >= 40) { console.log(`JailBustSniper: ⚠️ Timeout waiting for Yes button`); // Store result sessionStorage.setItem('ff-bust-result', JSON.stringify({ userName: userName, status: 'error', message: 'Confirmation timeout - you may need to click Yes manually', timestamp: Date.now() })); clearInterval(checkInterval); } }, 500); }, 1000); }, 2000); // Wait 2 seconds for page to fully load } catch (e) { console.error('JailBustSniper: Error processing pending bust:', e); sessionStorage.removeItem('ff-pending-bust'); } } /** * Display API budget monitor */ function displayAPIBudget() { let budgetEl = document.getElementById('ff-api-budget'); if (!budgetEl) { budgetEl = document.createElement('div'); budgetEl.id = 'ff-api-budget'; budgetEl.className = 'ff-api-budget'; document.body.appendChild(budgetEl); } function update() { const stats = APIBudgetTracker.getStats(); budgetEl.textContent = `${stats.used}/${stats.limit}`; /* Removed "API: " prefix */ budgetEl.className = 'ff-api-budget'; if (stats.percentage > 80) budgetEl.className += ' danger'; else if (stats.percentage > 60) budgetEl.className += ' warning'; } // CRITICAL: Clear any existing interval to prevent multiple timers if (_apiBudgetIntervalId !== null) { console.log('Clearing existing API budget interval:', _apiBudgetIntervalId); clearInterval(_apiBudgetIntervalId); _apiBudgetIntervalId = null; } // Initial update update(); // Create new interval and store its ID _apiBudgetIntervalId = setInterval(update, 2000); console.log('Created new API budget interval:', _apiBudgetIntervalId); } /** * Initialize smart status checking for visible players on page load */ async function initSmartStatusChecking() { // Only run on faction/bounty pages const isFactionPage = window.location.href.includes('factions.php'); const isBountyPage = window.location.href.includes('bounties.php'); if (!isFactionPage && !isBountyPage) return; // Check if this is first load or reload by checking RSI cache const rsiStats = await RSICacheManager.getStats(); if (rsiStats.totalPlayers === 0) { console.log('Smart Status: First load detected (RSI cache empty) - skipping bulk status check'); console.log('Smart Status: Status data will be cached from RSI API calls'); return; // First load - RSI API calls will get and cache all status data } console.log(`Smart Status: Reload detected (${rsiStats.totalPlayers} players in RSI cache) - checking for expired status`); // Get all visible player IDs on current page const visibleUserIds = []; document.querySelectorAll('a[href*="profiles.php?XID="]').forEach(link => { const match = link.href.match(/XID=(\d+)/); if (match) { const userId = parseInt(match[1], 10); if (!visibleUserIds.includes(userId)) { visibleUserIds.push(userId); } } }); if (visibleUserIds.length === 0) { console.log('Smart Status: No visible players found'); return; } console.log(`Smart Status: Found ${visibleUserIds.length} visible players on page`); // Check which of the VISIBLE users are already cached const cachedUsers = await StatusCacheManager.getUsersWithRecentStatus(); const cachedVisibleUsers = visibleUserIds.filter(id => cachedUsers.includes(id)); const uncachedUsers = visibleUserIds.filter(id => !cachedUsers.includes(id)); console.log(`Smart Status: ${cachedVisibleUsers.length} users cached with active status, ${uncachedUsers.length} need checking`); // Always queue ONLY the uncached users (never re-check cached ones) if (uncachedUsers.length === 0) { console.log('Smart Status: All visible users with status are cached - nothing to check'); return; } console.log(`Smart Status: Queueing ${uncachedUsers.length} uncached users for status check`); StatusCacheManager.queueStatusCheck(uncachedUsers); } /** * Background Status Refresh Timer * Periodically refreshes status for all visible users (every 3 minutes) * This keeps status data fresh without requiring page reloads */ function startBackgroundStatusRefresh() { // Only run on faction/bounty pages const isFactionPage = window.location.href.includes('factions.php'); const isBountyPage = window.location.href.includes('bounties.php'); if (!isFactionPage && !isBountyPage) { console.log('Background Status Refresh: Not on faction/bounty page - skipping'); return; } console.log('Background Status Refresh: Starting timer (refreshes every 3 minutes)'); // Function to refresh all visible users async function refreshVisibleUsers() { const visibleUserIds = []; document.querySelectorAll('a[href*="profiles.php?XID="]').forEach(link => { const match = link.href.match(/XID=(\d+)/); if (match) { const userId = parseInt(match[1], 10); if (!visibleUserIds.includes(userId)) { visibleUserIds.push(userId); } } }); if (visibleUserIds.length === 0) { console.log('Background Status Refresh: No visible users found'); return; } console.log(`Background Status Refresh: Refreshing ${visibleUserIds.length} visible users`); // Refresh each user for (const userId of visibleUserIds) { // Make lightweight status-only API call ApiManager.get(`/user/${userId}?selections=profile`, async d => { // Mark as checked LastCheckCacheManager.set(userId); // Update status cache if user has active status if (d.status || d.travel) { await StatusCacheManager.set(userId, d); // Update UI if element still exists const honorTextWrap = document.querySelector(`[data-user-id="${userId}"]`); if (honorTextWrap) { addStatusIcon(d, honorTextWrap, userId); } } }); // Small delay between requests to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 100)); } console.log('Background Status Refresh: Refresh complete'); } // Run refresh every 3 minutes setInterval(refreshVisibleUsers, LastCheckCacheManager.CHECK_INTERVAL); } function init() { console.log('%c═══════════════════════════════════════════════════════════', 'color: #4CAF50; font-weight: bold'); console.log('%c ATK SCOUTER v' + VERSION, 'color: #4CAF50; font-weight: bold; font-size: 14px'); console.log('%c═══════════════════════════════════════════════════════════', 'color: #4CAF50; font-weight: bold'); console.log('%c✨ Intelligent RSI Caching (7-day persistent cache)', 'color: #2196F3'); console.log('%c🏥 Real-time Status Tracking (hospital/traveling indicators)', 'color: #2196F3'); console.log('%c⚡ Smart Refresh (0 API calls on reload within 3 minutes)', 'color: #2196F3'); console.log('%c🔄 Auto-Background Updates (status refresh every 3 minutes)', 'color: #2196F3'); console.log('%c🎯 Fight Intelligence (ML-powered win probability)', 'color: #2196F3'); console.log('%c═══════════════════════════════════════════════════════════', 'color: #4CAF50; font-weight: bold'); console.log('Initializing components...'); createSettingsUI(); initUserState(); displayAPIBudget(); // Start background status refresh timer (Option 1 + Option 4 combo) // This refreshes all visible users every 3 minutes in the background // Combined with LastCheckCache, prevents duplicate refreshes on page reload startBackgroundStatusRefresh(); // Check if on attack-related page immediately FightCapture.setupAttackPageObserver(); // Auto-sync learning model from IndexedDB (runs async in background) autoSyncLearningModel(); // Check for pending bust (after navigation) checkPendingBust(); console.log('%c✓ ATK Scouter ready!', 'color: #4CAF50; font-weight: bold'); } // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();