EU-Utility/plugins/skill_scanner/plugin.py

673 lines
25 KiB
Python

"""
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")