""" EU-Utility - Skill Scanner Plugin Uses core OCR and Log services via PluginAPI. """ import re import json from datetime import datetime from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, QProgressBar, QFrame, QGroupBox, QTextEdit, QSplitter, QComboBox ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer from plugins.base_plugin import BasePlugin def find_entropia_window(): """ Find the Entropia Universe game window. Returns (left, top, width, height) or None if not found. """ try: import platform if platform.system() == 'Windows': import win32gui import win32process import psutil def callback(hwnd, windows): if not win32gui.IsWindowVisible(hwnd): return True # Get window title title = win32gui.GetWindowText(hwnd) # Check if it's an Entropia window (title contains "Entropia Universe") if 'Entropia Universe' in title: try: # Get process ID _, pid = win32process.GetWindowThreadProcessId(hwnd) process = psutil.Process(pid) # Verify process name contains "Entropia" if 'entropia' in process.name().lower(): rect = win32gui.GetWindowRect(hwnd) left, top, right, bottom = rect windows.append((left, top, right - left, bottom - top, title)) except: pass return True windows = [] win32gui.EnumWindows(callback, windows) if windows: # Return the largest window (most likely the main game) windows.sort(key=lambda w: w[2] * w[3], reverse=True) left, top, width, height, title = windows[0] print(f"[SkillScanner] Found Entropia window: '{title}' at ({left}, {top}, {width}, {height})") return (left, top, width, height) elif platform.system() == 'Linux': # Try using xdotool or wmctrl try: import subprocess result = subprocess.run( ['xdotool', 'search', '--name', 'Entropia Universe'], capture_output=True, text=True ) if result.returncode == 0 and result.stdout.strip(): window_id = result.stdout.strip().split('\n')[0] # Get window geometry geo_result = subprocess.run( ['xdotool', 'getwindowgeometry', window_id], capture_output=True, text=True ) if geo_result.returncode == 0: # Parse: "Position: 100,200 (screen: 0)" and "Geometry: 1920x1080" pos_match = re.search(r'Position: (\d+),(\d+)', geo_result.stdout) geo_match = re.search(r'Geometry: (\d+)x(\d+)', geo_result.stdout) if pos_match and geo_match: left = int(pos_match.group(1)) top = int(pos_match.group(2)) width = int(geo_match.group(1)) height = int(geo_match.group(2)) print(f"[SkillScanner] Found Entropia window at ({left}, {top}, {width}, {height})") return (left, top, width, height) except Exception as e: print(f"[SkillScanner] Linux window detection failed: {e}") except Exception as e: print(f"[SkillScanner] Window detection error: {e}") print("[SkillScanner] Could not find Entropia window - will use full screen") return None def capture_entropia_region(region=None): """ Capture screen region of Entropia window. If region is None, tries to find the window automatically. Returns PIL Image or None. """ try: from PIL import ImageGrab if region is None: region = find_entropia_window() if region: left, top, width, height = region # Add some padding to ensure we capture everything # Don't go below 0 left = max(0, left) top = max(0, top) screenshot = ImageGrab.grab(bbox=(left, top, left + width, top + height)) print(f"[SkillScanner] Captured Entropia window region: {width}x{height}") return screenshot else: # Fallback to full screen screenshot = ImageGrab.grab() print("[SkillScanner] Captured full screen (window not found)") return screenshot except Exception as e: print(f"[SkillScanner] Capture error: {e}") return None def is_valid_skill_text(text): """ Filter out non-game text from OCR results. Returns True if text looks like it could be from the game. """ # List of patterns that indicate NON-game text (UI, Discord, etc.) invalid_patterns = [ 'Discord', 'Presence', 'Event Bus', 'Example', 'Game Reader', 'Test', 'Page Scanner', 'HOTKEY MODE', 'Skill Tracker', 'Navigate', 'window', 'UI', 'Plugin', 'Settings', 'Click', 'Button', 'Menu', 'Panel', 'Tab', 'Loading...', 'Calculator', 'Nexus Search', 'Dashboard', 'Analytics', 'Multi-Page', 'Scanner', 'Auto-detect', 'F12', 'Cleared', 'Parsed:', '[SkillScanner]', 'INFO', 'DEBUG', 'Loading', 'Initializing', 'Connecting', 'Error:', 'Warning:', 'Entropia.exe', 'Client (64 bit)', 'Arkadia', 'Calypso', ] # Check for invalid patterns text_upper = text.upper() for pattern in invalid_patterns: if pattern.upper() in text_upper: return False # Check for reasonable skill name length (not too long, not too short) words = text.split() if len(words) > 10: # Skills don't have 10+ words return False return True class SkillOCRThread(QThread): """OCR scan using core service.""" scan_complete = pyqtSignal(dict) scan_error = pyqtSignal(str) progress_update = pyqtSignal(str) def __init__(self, ocr_service): super().__init__() self.ocr_service = ocr_service def run(self): """Perform OCR using core service - only on Entropia window.""" try: self.progress_update.emit("Finding Entropia window...") # Capture only the Entropia game window screenshot = capture_entropia_region() if screenshot is None: self.scan_error.emit("Could not capture game window") return self.progress_update.emit("Running OCR on game window...") # Use core OCR service with the captured image result = self.ocr_service.recognize_image(screenshot) if 'error' in result and result['error']: self.scan_error.emit(result['error']) return self.progress_update.emit("Filtering and parsing skills...") # Filter out non-game text and parse skills raw_text = result.get('text', '') skills_data = self._parse_skills_filtered(raw_text) if not skills_data: self.progress_update.emit("No valid skills found. Make sure Skills window is open.") else: self.progress_update.emit(f"Found {len(skills_data)} skills") self.scan_complete.emit(skills_data) except Exception as e: self.scan_error.emit(str(e)) def _parse_skills(self, text): """Parse skill data from OCR text with improved handling for 3-column layout.""" skills = {} # Ranks in Entropia Universe (including multi-word ranks) # Single word ranks SINGLE_RANKS = [ 'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average', 'Skilled', 'Expert', 'Professional', 'Master', 'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable', 'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome' ] # Multi-word ranks (must be checked first - longer matches first) MULTI_RANKS = [ 'Arch Master', 'Grand Master' ] # Combine: multi-word first (so they match before single word), then single ALL_RANKS = MULTI_RANKS + SINGLE_RANKS rank_pattern = '|'.join(ALL_RANKS) # Clean up the text - remove common headers and junk text = text.replace('SKILLS', '').replace('ALL CATEGORIES', '') text = text.replace('SKILL NAME', '').replace('RANK', '').replace('POINTS', '') # Remove category names that appear as standalone words for category in ['Attributes', 'COMBAT', 'Combat', 'Design', 'Construction', 'Defense', 'General', 'Handgun', 'Heavy Melee Weapons', 'Heavy Weapons', 'Information', 'Inflict Melee Damage', 'Inflict Ranged Damage', 'Light Melee Weapons', 'Longblades', 'Medical', 'Mining', 'Science', 'Social', 'Beauty', 'Mindforce']: text = text.replace(category, ' ') # Remove extra whitespace text = ' '.join(text.split()) # Find all skills in the text using finditer for match in re.finditer( rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)', text, re.IGNORECASE ): skill_name = match.group(1).strip() rank = match.group(2) points = int(match.group(3)) # Clean up skill name - remove common words that might be prepended skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) skill_name = skill_name.strip() # Validate - points should be reasonable (not too small) if points > 0 and skill_name and len(skill_name) > 2: skills[skill_name] = { 'rank': rank, 'points': points, 'scanned_at': datetime.now().isoformat() } print(f"[SkillScanner] Parsed: {skill_name} = {rank} ({points})") # If no skills found with primary method, try alternative if not skills: skills = self._parse_skills_alternative(text, ALL_RANKS) return skills def _parse_skills_filtered(self, text): """ Parse skills with filtering to remove non-game text. Only returns skills that pass validity checks. """ # First, split text into lines and filter each line lines = text.split('\n') valid_lines = [] for line in lines: line = line.strip() if not line: continue # Check if line contains a rank (required for skill lines) has_rank = any(rank in line for rank in [ 'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average', 'Skilled', 'Expert', 'Professional', 'Master', 'Arch Master', 'Grand Master', # Multi-word first 'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable', 'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome' ]) if not has_rank: continue # Skip lines without ranks # Check for invalid patterns if not is_valid_skill_text(line): print(f"[SkillScanner] Filtered out: '{line[:50]}...'") continue valid_lines.append(line) # Join valid lines and parse filtered_text = '\n'.join(valid_lines) if not filtered_text.strip(): print("[SkillScanner] No valid game text found after filtering") return {} print(f"[SkillScanner] Filtered {len(lines)} lines to {len(valid_lines)} valid lines") return self._parse_skills(filtered_text) def _parse_skills_alternative(self, text, ranks): """Alternative parser for when text is heavily merged.""" skills = {} # Find all rank positions in the text for rank in ranks: # Look for pattern: [text] [Rank] [number] pattern = rf'([A-Z][a-z]{{2,}}(?:\s+[A-Z][a-z]{{2,}}){{0,3}})\s+{re.escape(rank)}\s+(\d{{1,6}})' matches = re.finditer(pattern, text, re.IGNORECASE) for match in matches: skill_name = match.group(1).strip() points = int(match.group(2)) # Clean skill name skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) if points > 0 and len(skill_name) > 2: skills[skill_name] = { 'rank': rank, 'points': points, 'scanned_at': datetime.now().isoformat() } return skills class SignalHelper(QObject): """Helper QObject to hold signals since BasePlugin doesn't inherit from QObject.""" hotkey_triggered = pyqtSignal() update_status_signal = pyqtSignal(str, bool, bool) # message, success, error update_session_table_signal = pyqtSignal(object) # skills dict update_counters_signal = pyqtSignal() enable_scan_button_signal = pyqtSignal(bool) class SkillScannerPlugin(BasePlugin): """Scan skills using core OCR and track gains via core Log service.""" name = "Skill Scanner" version = "2.1.0" author = "ImpulsiveFPS" description = "Uses core OCR and Log services" hotkey = "ctrl+shift+s" def initialize(self): """Setup skill scanner.""" # Create signal helper (QObject) for thread-safe UI updates self._signals = SignalHelper() self.data_file = Path("data/skill_tracker.json") self.data_file.parent.mkdir(parents=True, exist_ok=True) # Load saved data self.skills_data = {} self.skill_gains = [] self._load_data() # Multi-page scanning state self.current_scan_session = {} # Skills collected in current multi-page scan self.pages_scanned = 0 # Connect signals (using signal helper QObject) self._signals.hotkey_triggered.connect(self._scan_page_for_multi) self._signals.update_status_signal.connect(self._update_multi_page_status_slot) self._signals.update_session_table_signal.connect(self._update_session_table) self._signals.update_counters_signal.connect(self._update_counters_slot) # Note: enable_scan_button_signal connected in get_ui() after button created # Subscribe to skill gain events from core Log service try: from core.plugin_api import get_api api = get_api() # Check if log service available log_service = api.services.get('log') if log_service: print(f"[SkillScanner] Connected to core Log service") except Exception as e: print(f"[SkillScanner] Could not connect to Log service: {e}") def _load_data(self): """Load saved skill data.""" if self.data_file.exists(): try: with open(self.data_file, 'r') as f: data = json.load(f) self.skills_data = data.get('skills', {}) self.skill_gains = data.get('gains', []) except: pass def _save_data(self): """Save skill data.""" with open(self.data_file, 'w') as f: json.dump({ 'skills': self.skills_data, 'gains': self.skill_gains }, f, indent=2) def get_ui(self): """Create skill scanner UI.""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(15) layout.setContentsMargins(0, 0, 0, 0) # Header header = QLabel("Skill Tracker") header.setStyleSheet("font-size: 18px; font-weight: bold; color: white;") layout.addWidget(header) # Info about core services info = self._get_service_status() info_label = QLabel(info) info_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") layout.addWidget(info_label) # Splitter splitter = QSplitter(Qt.Orientation.Vertical) # Scan section scan_group = QGroupBox("OCR Scan (Core Service)") scan_layout = QVBoxLayout(scan_group) # Buttons row buttons_layout = QHBoxLayout() scan_btn = QPushButton("Scan Skills Window") scan_btn.setStyleSheet(""" QPushButton { background-color: #ff8c42; color: white; padding: 12px; border: none; border-radius: 4px; font-weight: bold; } """) scan_btn.clicked.connect(self._scan_skills) buttons_layout.addWidget(scan_btn) reset_btn = QPushButton("Reset Data") reset_btn.setStyleSheet(""" QPushButton { background-color: #ff4757; color: white; padding: 12px; border: none; border-radius: 4px; font-weight: bold; } """) reset_btn.clicked.connect(self._reset_data) buttons_layout.addWidget(reset_btn) scan_layout.addLayout(buttons_layout) self.scan_progress = QLabel("Ready to scan") self.scan_progress.setStyleSheet("color: rgba(255,255,255,150);") scan_layout.addWidget(self.scan_progress) self.skills_table = QTableWidget() self.skills_table.setColumnCount(3) self.skills_table.setHorizontalHeaderLabels(["Skill", "Rank", "Points"]) self.skills_table.horizontalHeader().setStretchLastSection(True) scan_layout.addWidget(self.skills_table) splitter.addWidget(scan_group) # Multi-Page Scanning section multi_page_group = QGroupBox("Multi-Page Scanner") multi_page_layout = QVBoxLayout(multi_page_group) # Mode selection mode_layout = QHBoxLayout() mode_layout.addWidget(QLabel("Mode:")) self.scan_mode_combo = QComboBox() self.scan_mode_combo.addItems(["Smart Auto + Hotkey Fallback", "Manual Hotkey Only", "Manual Click Only"]) self.scan_mode_combo.currentIndexChanged.connect(self._on_scan_mode_changed) mode_layout.addWidget(self.scan_mode_combo) mode_layout.addStretch() multi_page_layout.addLayout(mode_layout) # Instructions self.instructions_label = QLabel( "🤖 SMART MODE:\n" "1. Position Skills window\n" "2. Click 'Start Smart Scan'\n" "3. Navigate pages in EU - auto-detect will scan for you\n" "4. If auto fails, use hotkey F12 to scan manually\n" "5. Click 'Save All' when done" ) self.instructions_label.setStyleSheet("color: #888; font-size: 11px;") multi_page_layout.addWidget(self.instructions_label) # Hotkey info self.hotkey_info = QLabel("Hotkey: F12 = Scan Current Page") self.hotkey_info.setStyleSheet("color: #4ecdc4; font-weight: bold;") multi_page_layout.addWidget(self.hotkey_info) # Status row status_layout = QHBoxLayout() self.multi_page_status = QLabel("⏳ Ready to scan page 1") self.multi_page_status.setStyleSheet("color: #ff8c42; font-size: 14px;") status_layout.addWidget(self.multi_page_status) self.pages_scanned_label = QLabel("Pages: 0") self.pages_scanned_label.setStyleSheet("color: #4ecdc4;") status_layout.addWidget(self.pages_scanned_label) self.total_skills_label = QLabel("Skills: 0") self.total_skills_label.setStyleSheet("color: #4ecdc4;") status_layout.addWidget(self.total_skills_label) status_layout.addStretch() multi_page_layout.addLayout(status_layout) # Buttons mp_buttons_layout = QHBoxLayout() self.scan_page_btn = QPushButton("▶️ Start Smart Scan") self.scan_page_btn.setStyleSheet(""" QPushButton { background-color: #4ecdc4; color: #141f23; padding: 12px; border: none; border-radius: 4px; font-weight: bold; font-size: 13px; } QPushButton:hover { background-color: #3dbdb4; } """) self.scan_page_btn.clicked.connect(self._start_smart_scan) mp_buttons_layout.addWidget(self.scan_page_btn) # Connect signal helper for button enabling (now that button exists) self._signals.enable_scan_button_signal.connect(self.scan_page_btn.setEnabled) save_all_btn = QPushButton("💾 Save All Scanned") save_all_btn.setStyleSheet(""" QPushButton { background-color: #ff8c42; color: white; padding: 12px; border: none; border-radius: 4px; font-weight: bold; font-size: 13px; } """) save_all_btn.clicked.connect(self._save_multi_page_scan) mp_buttons_layout.addWidget(save_all_btn) clear_session_btn = QPushButton("🗑 Clear Session") clear_session_btn.setStyleSheet(""" QPushButton { background-color: #666; color: white; padding: 12px; border: none; border-radius: 4px; } """) clear_session_btn.clicked.connect(self._clear_multi_page_session) mp_buttons_layout.addWidget(clear_session_btn) multi_page_layout.addLayout(mp_buttons_layout) # Current session table self.session_table = QTableWidget() self.session_table.setColumnCount(3) self.session_table.setHorizontalHeaderLabels(["Skill", "Rank", "Points"]) self.session_table.horizontalHeader().setStretchLastSection(True) self.session_table.setMaximumHeight(200) self.session_table.setStyleSheet(""" QTableWidget { background-color: #0d1117; border: 1px solid #333; } QTableWidget::item { padding: 4px; color: #c9d1d9; } """) multi_page_layout.addWidget(self.session_table) splitter.addWidget(multi_page_group) # Log section log_group = QGroupBox("Log Tracking (Core Service)") log_layout = QVBoxLayout(log_group) log_status = QLabel("Skill gains tracked from chat log") log_status.setStyleSheet("color: #4ecdc4;") log_layout.addWidget(log_status) self.gains_text = QTextEdit() self.gains_text.setReadOnly(True) self.gains_text.setMaximumHeight(150) self.gains_text.setPlaceholderText("Recent skill gains from core Log service...") log_layout.addWidget(self.gains_text) self.total_gains_label = QLabel(f"Total gains: {len(self.skill_gains)}") self.total_gains_label.setStyleSheet("color: rgba(255,255,255,150);") log_layout.addWidget(self.total_gains_label) splitter.addWidget(log_group) layout.addWidget(splitter) self._refresh_skills_table() return widget def _get_service_status(self) -> str: """Get status of core services.""" try: from core.ocr_service import get_ocr_service from core.log_reader import get_log_reader ocr = get_ocr_service() log = get_log_reader() ocr_status = "✓" if ocr.is_available() else "✗" log_status = "✓" if log.is_available() else "✗" return f"Core Services - OCR: {ocr_status} Log: {log_status}" except: return "Core Services - status unknown" def _scan_skills(self): """Start OCR scan using core service.""" try: from core.ocr_service import get_ocr_service ocr_service = get_ocr_service() if not ocr_service.is_available(): self.scan_progress.setText("Error: OCR service not available") return self.scanner = SkillOCRThread(ocr_service) self.scanner.scan_complete.connect(self._on_scan_complete) self.scanner.scan_error.connect(self._on_scan_error) self.scanner.progress_update.connect(self._on_scan_progress) self.scanner.start() except Exception as e: self.scan_progress.setText(f"Error: {e}") def _on_scan_progress(self, message): self.scan_progress.setText(message) def _on_scan_complete(self, skills_data): self.skills_data.update(skills_data) self._save_data() self._refresh_skills_table() self.scan_progress.setText(f"Found {len(skills_data)} skills") def _reset_data(self): """Reset all skill data.""" from PyQt6.QtWidgets import QMessageBox reply = QMessageBox.question( None, "Reset Skill Data", "Are you sure you want to clear all scanned skill data?\n\nThis cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.skills_data = {} self.skill_gains = [] self._save_data() self._refresh_skills_table() self.gains_text.clear() self.total_gains_label.setText("Total gains: 0") self.scan_progress.setText("Data cleared") def _on_scan_error(self, error): self.scan_progress.setText(f"Error: {error}") self.scan_progress.setText(f"Error: {error}") def _refresh_skills_table(self): self.skills_table.setRowCount(len(self.skills_data)) for i, (name, data) in enumerate(sorted(self.skills_data.items())): self.skills_table.setItem(i, 0, QTableWidgetItem(name)) self.skills_table.setItem(i, 1, QTableWidgetItem(data.get('rank', '-'))) self.skills_table.setItem(i, 2, QTableWidgetItem(str(data.get('points', 0)))) def _scan_page_for_multi(self): """Scan current page and add to multi-page session.""" from PyQt6.QtCore import QTimer self.multi_page_status.setText("📷 Scanning...") self.multi_page_status.setStyleSheet("color: #ffd93d;") self.scan_page_btn.setEnabled(False) # Run scan in thread from threading import Thread def do_scan(): try: from core.ocr_service import get_ocr_service import re from datetime import datetime ocr_service = get_ocr_service() if not ocr_service.is_available(): self._signals.update_status_signal.emit("Error: OCR not available", False, True) return # Capture and OCR result = ocr_service.recognize() text = result.get('text', '') # Parse skills skills = self._parse_skills_from_text(text) # Add to session for skill_name, data in skills.items(): self.current_scan_session[skill_name] = data self.pages_scanned += 1 # Update UI via signals (thread-safe) self._signals.update_session_table_signal.emit(self.current_scan_session) # Show success with checkmark and beep self._signals.update_status_signal.emit( f"✅ Page {self.pages_scanned} scanned! {len(skills)} skills found. Click Next Page in game →", True, False ) # Play beep sound self._play_beep() except Exception as e: self._signals.update_status_signal.emit(f"Error: {str(e)}", False, True) finally: self._signals.enable_scan_button_signal.emit(True) thread = Thread(target=do_scan) thread.daemon = True thread.start() def _parse_skills_from_text(self, text): """Parse skills from OCR text.""" skills = {} # Ranks in Entropia Universe - multi-word first for proper matching SINGLE_RANKS = [ 'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average', 'Skilled', 'Expert', 'Professional', 'Master', 'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable', 'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome' ] MULTI_RANKS = ['Arch Master', 'Grand Master'] ALL_RANKS = MULTI_RANKS + SINGLE_RANKS rank_pattern = '|'.join(ALL_RANKS) # Clean text text = text.replace('SKILLS', '').replace('ALL CATEGORIES', '') text = text.replace('SKILL NAME', '').replace('RANK', '').replace('POINTS', '') # Remove category names for category in ['Attributes', 'COMBAT', 'Combat', 'Design', 'Construction', 'Defense', 'General', 'Handgun', 'Heavy Melee Weapons', 'Heavy Weapons', 'Information', 'Inflict Melee Damage', 'Inflict Ranged Damage', 'Light Melee Weapons', 'Longblades', 'Medical', 'Mining', 'Science', 'Social', 'Beauty', 'Mindforce']: text = text.replace(category, ' ') text = ' '.join(text.split()) # Find all skills import re for match in re.finditer( rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)', text, re.IGNORECASE ): skill_name = match.group(1).strip() rank = match.group(2) points = int(match.group(3)) skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) skill_name = skill_name.strip() if points > 0 and skill_name and len(skill_name) > 2: skills[skill_name] = {'rank': rank, 'points': points} return skills def _update_multi_page_status_slot(self, message, success=False, error=False): """Slot for updating multi-page status (called via signal).""" color = "#4ecdc4" if success else "#ff4757" if error else "#ff8c42" self.multi_page_status.setText(message) self.multi_page_status.setStyleSheet(f"color: {color}; font-size: 14px;") self._update_counters_slot() def _update_counters_slot(self): """Slot for updating counters (called via signal).""" self.pages_scanned_label.setText(f"Pages: {self.pages_scanned}") self.total_skills_label.setText(f"Skills: {len(self.current_scan_session)}") def _play_beep(self): """Play a beep sound to notify user.""" try: import winsound winsound.MessageBeep(winsound.MB_OK) except: # Fallback - try to use system beep try: print('\a') # ASCII bell character except: pass def _update_session_table(self, skills): """Update the session table with current scan data.""" self.session_table.setRowCount(len(skills)) for i, (name, data) in enumerate(sorted(skills.items())): self.session_table.setItem(i, 0, QTableWidgetItem(name)) self.session_table.setItem(i, 1, QTableWidgetItem(data.get('rank', '-'))) self.session_table.setItem(i, 2, QTableWidgetItem(str(data.get('points', 0)))) def _save_multi_page_scan(self): """Save all scanned skills from multi-page session.""" if not self.current_scan_session: from PyQt6.QtWidgets import QMessageBox QMessageBox.information(None, "No Data", "No skills scanned yet. Scan some pages first!") return # Merge with existing data self.skills_data.update(self.current_scan_session) self._save_data() self._refresh_skills_table() from PyQt6.QtWidgets import QMessageBox QMessageBox.information( None, "Scan Complete", f"Saved {len(self.current_scan_session)} skills from {self.pages_scanned} pages!" ) # Clear session after saving self._clear_multi_page_session() def _clear_multi_page_session(self): """Clear the current multi-page scanning session.""" self.current_scan_session = {} self.pages_scanned = 0 self.auto_scan_active = False self.session_table.setRowCount(0) self.multi_page_status.setText("⏳ Ready to scan page 1") self.multi_page_status.setStyleSheet("color: #ff8c42; font-size: 14px;") self.pages_scanned_label.setText("Pages: 0") self.total_skills_label.setText("Skills: 0") # Unregister hotkey if active self._unregister_hotkey() def _on_scan_mode_changed(self, index): """Handle scan mode change.""" modes = [ "🤖 SMART MODE:\n1. Position Skills window\n2. Click 'Start Smart Scan'\n3. Navigate pages - auto-detect will scan\n4. If auto fails, press F12\n5. Click 'Save All' when done", "⌨️ HOTKEY MODE:\n1. Position Skills window\n2. Navigate to page 1 in EU\n3. Press F12 to scan each page\n4. Click Next Page in EU\n5. Repeat F12 for each page", "🖱️ MANUAL MODE:\n1. Position Skills window\n2. Click 'Scan Current Page'\n3. Wait for beep\n4. Click Next Page in EU\n5. Repeat" ] self.instructions_label.setText(modes[index]) def _start_smart_scan(self): """Start smart auto-scan with hotkey fallback.""" mode = self.scan_mode_combo.currentIndex() if mode == 0: # Smart Auto + Hotkey self._start_auto_scan_with_hotkey() elif mode == 1: # Hotkey only self._register_hotkey() self._signals.update_status_signal.emit("Hotkey F12 ready! Navigate to first page and press F12", True, False) else: # Manual click self._scan_page_for_multi() def _start_auto_scan_with_hotkey(self): """Start auto-detection with fallback to hotkey.""" self.auto_scan_active = True self.auto_scan_failures = 0 self.last_page_number = None # Register F12 hotkey as fallback self._register_hotkey() # Start monitoring self._signals.update_status_signal.emit("🤖 Auto-detect started! Navigate to page 1...", True, False) # Start auto-detection timer self.auto_scan_timer = QTimer() self.auto_scan_timer.timeout.connect(self._check_for_page_change) self.auto_scan_timer.start(500) # Check every 500ms def _register_hotkey(self): """Register F12 hotkey for manual scan.""" try: import keyboard keyboard.on_press_key('f12', lambda e: self._hotkey_scan()) self.hotkey_registered = True except Exception as e: print(f"[SkillScanner] Could not register hotkey: {e}") self.hotkey_registered = False def _unregister_hotkey(self): """Unregister hotkey.""" try: if hasattr(self, 'hotkey_registered') and self.hotkey_registered: import keyboard keyboard.unhook_all() self.hotkey_registered = False except: pass # Stop auto-scan timer if hasattr(self, 'auto_scan_timer') and self.auto_scan_timer: self.auto_scan_timer.stop() self.auto_scan_active = False def _hotkey_scan(self): """Scan triggered by F12 hotkey - thread safe via signal.""" # Emit signal to safely call from hotkey thread self._signals.hotkey_triggered.emit() def _check_for_page_change(self): """Auto-detect page changes by monitoring page number area.""" if not self.auto_scan_active: return try: from PIL import ImageGrab import pytesseract # Capture page number area (bottom center of skills window) # This is approximate - may need adjustment screen = ImageGrab.grab() width, height = screen.size # Try to capture the page number area (bottom center, small region) # EU skills window shows page like "1/12" at bottom page_area = (width // 2 - 50, height - 100, width // 2 + 50, height - 50) page_img = ImageGrab.grab(bbox=page_area) # OCR just the page number page_text = pytesseract.image_to_string(page_img, config='--psm 7 -c tessedit_char_whitelist=0123456789/') # Extract current page number import re match = re.search(r'(\d+)/(\d+)', page_text) if match: current_page = int(match.group(1)) total_pages = int(match.group(2)) # If page changed, trigger scan if self.last_page_number is not None and current_page != self.last_page_number: self._signals.update_status_signal.emit(f"📄 Page change detected: {current_page}/{total_pages}", True, False) self._scan_page_for_multi() self.last_page_number = current_page else: # Failed to detect page number self.auto_scan_failures += 1 if self.auto_scan_failures >= 10: # After 5 seconds of failures self._fallback_to_hotkey() except Exception as e: self.auto_scan_failures += 1 if self.auto_scan_failures >= 10: self._fallback_to_hotkey() def _fallback_to_hotkey(self): """Fallback to hotkey mode when auto-detection fails.""" if hasattr(self, 'auto_scan_timer') and self.auto_scan_timer: self.auto_scan_timer.stop() self.auto_scan_active = False # Keep hotkey registered self._signals.update_status_signal.emit( "⚠️ Auto-detect unreliable. Use F12 hotkey to scan each page manually!", False, True ) # Play alert sound self._play_beep()