""" 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 ) from PyQt6.QtCore import Qt, QThread, pyqtSignal from plugins.base_plugin import BasePlugin 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.""" try: self.progress_update.emit("Capturing screen...") # Use core OCR service result = self.ocr_service.recognize() if 'error' in result and result['error']: self.scan_error.emit(result['error']) return self.progress_update.emit("Parsing skills...") # Parse skills from text skills_data = self._parse_skills(result.get('text', '')) 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_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 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.""" 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 # 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) # Instructions instructions = QLabel( "1. Position your Skills window to show skills\n" "2. Click 'Scan Page' below\n" "3. When you hear the beep, click Next Page in game\n" "4. Repeat until all pages are scanned\n" "5. Click 'Save All' to store combined results" ) instructions.setStyleSheet("color: #888; font-size: 11px;") multi_page_layout.addWidget(instructions) # 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("📷 Scan Current Page") 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._scan_page_for_multi) mp_buttons_layout.addWidget(self.scan_page_btn) 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._update_multi_page_status("Error: OCR not available", error=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 from PyQt6.QtCore import QMetaObject, Qt, Q_ARG QMetaObject.invokeMethod( self, "_update_session_table", Qt.ConnectionType.QueuedConnection, Q_ARG(object, self.current_scan_session) ) # Show success with checkmark and beep self._update_multi_page_status( f"✅ Page {self.pages_scanned} scanned! {len(skills)} skills found. Click Next Page in game →", success=True ) # Play beep sound self._play_beep() except Exception as e: self._update_multi_page_status(f"Error: {str(e)}", error=True) finally: from PyQt6.QtCore import QMetaObject, Qt, Q_ARG QMetaObject.invokeMethod( self.scan_page_btn, "setEnabled", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, 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(self, message, success=False, error=False): """Update multi-page status label.""" from PyQt6.QtCore import QMetaObject, Qt, Q_ARG color = "#4ecdc4" if success else "#ff4757" if error else "#ff8c42" QMetaObject.invokeMethod( self.multi_page_status, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, message) ) QMetaObject.invokeMethod( self.multi_page_status, "setStyleSheet", Qt.ConnectionType.QueuedConnection, Q_ARG(str, f"color: {color}; font-size: 14px;") ) # Update counters QMetaObject.invokeMethod( self.pages_scanned_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, f"Pages: {self.pages_scanned}") ) QMetaObject.invokeMethod( self.total_skills_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, 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.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")