""" 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, QObject 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 = [ # App/UI elements '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', # Instructions from our own UI 'Position Skills', 'Position Skills window', 'Start Smart Scan', 'Scan Current Page', 'Save All', 'Clear Session', 'Select Area', 'Drag over', 'Navigate pages', # Column headers that might be picked up 'Skill', 'Skills', 'Rank', 'Points', 'Name', # Category names with extra text 'Combat Wounding', 'Combat Serendipity', 'Combat Reflexes', 'Scan Serendipity', 'Scan Wounding', 'Scan Reflexes', 'Position Wounding', 'Position Serendipity', 'Position Reflexes', 'Current Page', 'Smart Scan', 'All Scanned', ] # 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) > 7: # Skills rarely have 7+ words (reduced from 10) return False # Check if text contains button/action words combined with skill-like text action_words = ['Click', 'Scan', 'Position', 'Select', 'Navigate', 'Start', 'Save', 'Clear'] text_lower = text.lower() for word in action_words: if word.lower() in text_lower: # If it has action words, it's probably UI text 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, scan_area=None): super().__init__() self.ocr_service = ocr_service self.scan_area = scan_area # (x, y, width, height) or None def run(self): """Perform OCR using core service - only on selected area or Entropia window.""" try: if self.scan_area: # Use user-selected area x, y, w, h = self.scan_area self.progress_update.emit(f"Capturing selected area ({w}x{h})...") from PIL import ImageGrab screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h)) else: # Capture Entropia game window self.progress_update.emit("Finding Entropia window...") screenshot = capture_entropia_region() if screenshot is None: self.scan_error.emit("Could not capture screen") return self.progress_update.emit("Running OCR...") # 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("Parsing skills...") # Parse skills from text raw_text = result.get('text', '') skills_data = self._parse_skills_filtered(raw_text) if not skills_data: self.progress_update.emit("No skills found. Make sure Skills window is visible.") 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)) 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 skill name - filter out UI text if not is_valid_skill_text(skill_name): print(f"[SkillScanner] Filtered invalid skill name: '{skill_name}'") continue # 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 SnippingWidget(QWidget): """Fullscreen overlay for snipping tool-style area selection.""" area_selected = pyqtSignal(int, int, int, int) # x, y, width, height cancelled = pyqtSignal() def __init__(self): super().__init__() self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) # Get screen geometry from PyQt6.QtWidgets import QApplication screen = QApplication.primaryScreen().geometry() self.setGeometry(screen) self.begin = None self.end = None self.drawing = False # Semi-transparent dark overlay self.overlay_color = Qt.GlobalColor.black self.overlay_opacity = 160 # 0-255 def paintEvent(self, event): from PyQt6.QtGui import QPainter, QPen, QColor, QBrush painter = QPainter(self) # Draw semi-transparent overlay overlay = QColor(0, 0, 0, self.overlay_opacity) painter.fillRect(self.rect(), overlay) if self.begin and self.end: # Clear overlay in selected area (make it transparent) x = min(self.begin.x(), self.end.x()) y = min(self.begin.y(), self.end.y()) w = abs(self.end.x() - self.begin.x()) h = abs(self.end.y() - self.begin.y()) # Draw the clear rectangle painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear) painter.fillRect(x, y, w, h, Qt.GlobalColor.transparent) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) # Draw border around selection pen = QPen(Qt.GlobalColor.white, 2, Qt.PenStyle.SolidLine) painter.setPen(pen) painter.drawRect(x, y, w, h) # Draw dimensions text painter.setPen(Qt.GlobalColor.white) from PyQt6.QtGui import QFont font = QFont("Arial", 10) painter.setFont(font) painter.drawText(x, y - 5, f"{w} x {h}") def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.begin = event.pos() self.end = event.pos() self.drawing = True self.update() def mouseMoveEvent(self, event): if self.drawing: self.end = event.pos() self.update() def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton and self.drawing: self.drawing = False self.end = event.pos() # Calculate selection rectangle x = min(self.begin.x(), self.end.x()) y = min(self.begin.y(), self.end.y()) w = abs(self.end.x() - self.begin.x()) h = abs(self.end.y() - self.begin.y()) # Minimum size check if w > 50 and h > 50: self.area_selected.emit(x, y, w, h) else: self.cancelled.emit() self.close() elif event.button() == Qt.MouseButton.RightButton: # Right click to cancel self.cancelled.emit() self.close() def keyPressEvent(self, event): if event.key() == Qt.Key.Key_Escape: self.cancelled.emit() self.close() 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 # Scan area selection (x, y, width, height) - None means auto-detect game window self.scan_area = None # 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) # Area selection row area_layout = QHBoxLayout() self.select_area_btn = QPushButton("📐 Select Area") self.select_area_btn.setStyleSheet(""" QPushButton { background-color: #4a9eff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #3a8eef; } """) self.select_area_btn.clicked.connect(self._start_area_selection) area_layout.addWidget(self.select_area_btn) self.area_label = QLabel("Area: Not selected (will scan full game window)") self.area_label.setStyleSheet("color: #888; font-size: 11px;") area_layout.addWidget(self.area_label) area_layout.addStretch() scan_layout.addLayout(area_layout) # 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. Click 'Select Area' above and drag over your 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 # Pass scan_area if user has selected one self.scanner = SkillOCRThread(ocr_service, scan_area=self.scan_area) 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 from PIL import ImageGrab 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 based on scan_area setting if self.scan_area: x, y, w, h = self.scan_area screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h)) else: screenshot = capture_entropia_region() if screenshot is None: self._signals.update_status_signal.emit("Error: Could not capture screen", False, True) return # OCR result = ocr_service.recognize_image(screenshot) 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 _start_area_selection(self): """Open snipping tool for user to select scan area.""" self.select_area_btn.setEnabled(False) self.select_area_btn.setText("📐 Selecting...") # Create and show snipping widget self.snipping_widget = SnippingWidget() self.snipping_widget.area_selected.connect(self._on_area_selected) self.snipping_widget.cancelled.connect(self._on_area_cancelled) self.snipping_widget.show() def _on_area_selected(self, x, y, w, h): """Handle area selection from snipping tool.""" self.scan_area = (x, y, w, h) self.area_label.setText(f"Area: {w}x{h} at ({x}, {y})") self.area_label.setStyleSheet("color: #4ecdc4; font-size: 11px;") self.select_area_btn.setEnabled(True) self.select_area_btn.setText("📐 Select Area") self._signals.update_status_signal.emit(f"✅ Scan area selected: {w}x{h}", True, False) def _on_area_cancelled(self): """Handle cancelled area selection.""" self.select_area_btn.setEnabled(True) self.select_area_btn.setText("📐 Select Area") self._signals.update_status_signal.emit("Area selection cancelled", False, False) 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() # Validate skill name - filter out UI text if not is_valid_skill_text(skill_name): print(f"[SkillScanner] Filtered invalid skill name: '{skill_name}'") continue 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. Click 'Select Area' and drag over 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. Click 'Select Area' and drag over 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. Click 'Select Area' and drag over 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()