EU-Utility/plugins/skill_scanner/plugin.py

1241 lines
48 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, 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()