commit 2f94cf85fc13ecfea0f783523d24a06c0c06b10b Author: LemonNexus Date: Sun Feb 15 01:40:08 2026 +0000 Initial commit: Plugin repository with 22 plugins Includes: - All current EU-Utility plugins - manifest.json with plugin metadata - README.md with developer documentation - Plugins organized by category (Tools, Tracking, Information, etc.) Categories: - Tools: Calculator, Crafting Calc, Enhancer Calc, etc. - Tracking: Skill Scanner, Loot Tracker, Mining Helper, etc. - Information: Nexus Search, Dashboard, Chat Logger - Market: Auction Tracker, Inventory Manager - Analytics, Media, Social, Navigation, Data Ready for EU-Utility Plugin Store integration. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1ca54e --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# EU-Utility Plugins Repository + +Official plugin repository for EU-Utility - the Entropia Universe addon framework. + +## About + +This repository contains plugins that extend EU-Utility's functionality. The core EU-Utility application is a framework-only addon loader - all user-facing features come from plugins. + +## Plugin Categories + +### Tools +- **Calculator** - PED/PEC calculations, DPP, markup +- **Crafting Calculator** - Crafting success rates and profit +- **Enhancer Calculator** - Break rate and cost analysis +- **DPP Calculator** - Advanced weapon efficiency +- **Universal Search** - Quick search across all plugins + +### Tracking +- **Skill Scanner** - OCR-based skill tracking +- **Loot Tracker** - Real-time loot tracking +- **Mining Helper** - Mining claim tracking +- **Mission Tracker** - Mission progress +- **Codex Tracker** - Mob codex progress +- **Global Tracker** - Globals and HOFs + +### Information +- **Nexus Search** - Entropia Nexus database +- **Dashboard** - Overview and stats +- **Chat Logger** - Advanced chat logging + +### Market +- **Auction Tracker** - Price tracking and alerts +- **Inventory Manager** - Item management + +### Analytics +- **Analytics** - Charts and visualizations + +### Media +- **Spotify Controller** - Music control + +### Social +- **Discord Presence** - Rich Discord status + +### Navigation +- **TP Runner** - Teleport helper + +### Data +- **Import/Export** - Backup and restore + +## For Developers + +### Plugin Structure +``` +plugins/your_plugin/ +├── __init__.py +└── plugin.py +``` + +### Minimum Requirements +```python +from plugins.base_plugin import BasePlugin + +class YourPlugin(BasePlugin): + name = "Your Plugin" + version = "1.0.0" + author = "Your Name" + description = "What your plugin does" + + def initialize(self): + # Setup code + pass + + def get_ui(self): + # Return QWidget for UI + return QWidget() +``` + +### Adding to Repository + +1. Create your plugin folder in `plugins/` +2. Add entry to `manifest.json` +3. Submit pull request + +### Manifest Format +```json +{ + "id": "your_plugin", + "name": "Your Plugin", + "version": "1.0.0", + "author": "Your Name", + "description": "Description", + "folder": "plugins/your_plugin/", + "icon": "icon_name", + "tags": ["tag1", "tag2"], + "dependencies": { + "core": ["ocr", "log"], + "plugins": ["other_plugin"] + }, + "min_core_version": "2.0.0", + "category": "Tools" +} +``` + +## Installation + +Plugins are installed through the EU-Utility Plugin Store: +1. Open EU-Utility Settings +2. Go to Plugin Store tab +3. Browse and install plugins + +Or manually: +1. Clone this repository +2. Copy plugin folder to EU-Utility's `plugins/` directory +3. Restart EU-Utility + +## Core Services Available + +Plugins can access these core services via PluginAPI: + +- **OCR** - Screen text recognition +- **Log Reader** - chat.log parsing +- **Nexus API** - Entropia Nexus database +- **Data Store** - Persistent storage +- **HTTP Client** - Network requests +- **Window Manager** - Game window detection +- **Screenshot** - Screen capture +- **Audio** - Sound playback +- **Notifications** - Toast notifications +- **Clipboard** - Copy/paste + +## License + +All plugins in this repository are released under MIT License. + +## Contributing + +1. Fork this repository +2. Create your plugin +3. Test thoroughly +4. Submit pull request + +## Support + +- Issues: Open issue on Gitea +- Discord: EU-Utility Discord server +- Documentation: See EU-Utility core docs diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2333f46 --- /dev/null +++ b/manifest.json @@ -0,0 +1,344 @@ +{ + "manifest_version": "1.0.0", + "repository_name": "EU-Utility Official Plugins", + "repository_url": "https://git.lemonlink.eu/impulsivefps/EU-Utility-Plugins-Repo", + "last_updated": "2026-02-15T00:00:00Z", + "plugins": [ + { + "id": "skill_scanner", + "name": "Skill Scanner", + "version": "2.1.0", + "author": "ImpulsiveFPS", + "description": "Scan and track your skills using OCR. Features multi-page scanning, progress tracking, and automatic skill gain detection from logs.", + "folder": "plugins/skill_scanner/", + "icon": "skills", + "tags": ["skills", "ocr", "tracking", "progress"], + "dependencies": { + "core": ["ocr", "log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "calculator", + "name": "Calculator", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "PED/PEC calculator with common EU formulas. Calculate DPP, markup, enhancer break rates, and more.", + "folder": "plugins/calculator/", + "icon": "calculator", + "tags": ["calculator", "math", "ped", "dpp"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "nexus_search", + "name": "Nexus Search", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Search Entropia Nexus database for items, mobs, blueprints, and locations. View market data and item details.", + "folder": "plugins/nexus_search/", + "icon": "search", + "tags": ["nexus", "search", "items", "market"], + "dependencies": { + "core": ["http"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Information" + }, + { + "id": "dashboard", + "name": "Dashboard", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Overview dashboard showing session stats, recent globals, skill gains, and quick access to all plugins.", + "folder": "plugins/dashboard/", + "icon": "grid", + "tags": ["dashboard", "overview", "stats"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Information" + }, + { + "id": "loot_tracker", + "name": "Loot Tracker", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Track hunting loot in real-time from game logs. Calculate TT value, markup, and profit/loss per session.", + "folder": "plugins/loot_tracker/", + "icon": "package", + "tags": ["loot", "hunting", "tracking", "profit"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tracking" + }, + { + "id": "mining_helper", + "name": "Mining Helper", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Mining assistant with claim tracking, resource mapping, and extraction calculator.", + "folder": "plugins/mining_helper/", + "icon": "pickaxe", + "tags": ["mining", "tracking", "resources"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tracking" + }, + { + "id": "crafting_calc", + "name": "Crafting Calculator", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Calculate crafting success rates, material costs, and profit margins. Track crafting skill gains.", + "folder": "plugins/crafting_calc/", + "icon": "tool", + "tags": ["crafting", "calculator", "profit"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "inventory_manager", + "name": "Inventory Manager", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Manage your inventory with item tracking, value calculation, and storage organization.", + "folder": "plugins/inventory_manager/", + "icon": "box", + "tags": ["inventory", "items", "storage"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Management" + }, + { + "id": "mission_tracker", + "name": "Mission Tracker", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Track mission progress, objectives, and rewards. Get notifications when missions complete.", + "folder": "plugins/mission_tracker/", + "icon": "check", + "tags": ["missions", "tracking", "quests"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tracking" + }, + { + "id": "enhancer_calc", + "name": "Enhancer Calculator", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Calculate enhancer break rates, costs, and optimal enhancer combinations for your gear.", + "folder": "plugins/enhancer_calc/", + "icon": "zap", + "tags": ["enhancers", "calculator", "gear"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "codex_tracker", + "name": "Codex Tracker", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Track Codex progress for all mobs. See rank rewards and completion status at a glance.", + "folder": "plugins/codex_tracker/", + "icon": "book", + "tags": ["codex", "tracking", "mobs", "rewards"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tracking" + }, + { + "id": "dpp_calculator", + "name": "DPP Calculator", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Advanced Damage Per PEC calculator with weapon comparison and efficiency analysis.", + "folder": "plugins/dpp_calculator/", + "icon": "trending-up", + "tags": ["dpp", "calculator", "weapons", "efficiency"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "tp_runner", + "name": "TP Runner", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Teleportation helper with location bookmarks and quick TP navigation.", + "folder": "plugins/tp_runner/", + "icon": "navigation", + "tags": ["teleport", "locations", "navigation"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Navigation" + }, + { + "id": "global_tracker", + "name": "Global Tracker", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Track globals and HOFs in real-time. View personal and planetary statistics.", + "folder": "plugins/global_tracker/", + "icon": "award", + "tags": ["globals", "hof", "tracking", "statistics"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tracking" + }, + { + "id": "auction_tracker", + "name": "Auction Tracker", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Track auction prices, bid history, and market trends. Set price alerts.", + "folder": "plugins/auction_tracker/", + "icon": "shopping-bag", + "tags": ["auction", "market", "prices", "alerts"], + "dependencies": { + "core": ["http"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Market" + }, + { + "id": "analytics", + "name": "Analytics", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Advanced analytics and visualizations for your Entropia data. Charts, graphs, and trends.", + "folder": "plugins/analytics/", + "icon": "trending-up", + "tags": ["analytics", "charts", "data", "visualization"], + "dependencies": { + "core": ["log", "data_store"], + "plugins": ["loot_tracker"] + }, + "min_core_version": "2.0.0", + "category": "Analytics" + }, + { + "id": "spotify_controller", + "name": "Spotify Controller", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Control Spotify playback from within EU-Utility. In-game overlay widget included.", + "folder": "plugins/spotify_controller/", + "icon": "music", + "tags": ["spotify", "music", "media", "overlay"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Media" + }, + { + "id": "discord_presence", + "name": "Discord Presence", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Rich Discord presence showing your current EU activity.", + "folder": "plugins/discord_presence/", + "icon": "message-square", + "tags": ["discord", "presence", "social"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Social" + }, + { + "id": "chat_logger", + "name": "Chat Logger", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Advanced chat logging with filtering, search, and export functionality.", + "folder": "plugins/chat_logger/", + "icon": "file", + "tags": ["chat", "logs", "history"], + "dependencies": { + "core": ["log"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "universal_search", + "name": "Universal Search", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Quick search across all plugins. Find items, skills, locations instantly.", + "folder": "plugins/universal_search/", + "icon": "search", + "tags": ["search", "quick", "universal"], + "dependencies": { + "core": [], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Tools" + }, + { + "id": "import_export", + "name": "Import/Export", + "version": "1.0.0", + "author": "ImpulsiveFPS", + "description": "Import and export your EU-Utility data. Backup and restore functionality.", + "folder": "plugins/import_export/", + "icon": "external", + "tags": ["import", "export", "backup", "data"], + "dependencies": { + "core": ["data_store"], + "plugins": [] + }, + "min_core_version": "2.0.0", + "category": "Data" + } + ] +} diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/__pycache__/__init__.cpython-312.pyc b/plugins/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8c67380 Binary files /dev/null and b/plugins/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/__pycache__/base_plugin.cpython-312.pyc b/plugins/__pycache__/base_plugin.cpython-312.pyc new file mode 100644 index 0000000..a0177d9 Binary files /dev/null and b/plugins/__pycache__/base_plugin.cpython-312.pyc differ diff --git a/plugins/analytics/__init__.py b/plugins/analytics/__init__.py new file mode 100644 index 0000000..76dab5e --- /dev/null +++ b/plugins/analytics/__init__.py @@ -0,0 +1,3 @@ +from .plugin import AnalyticsPlugin + +__all__ = ['AnalyticsPlugin'] diff --git a/plugins/analytics/__pycache__/__init__.cpython-312.pyc b/plugins/analytics/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d5af77a Binary files /dev/null and b/plugins/analytics/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/analytics/__pycache__/plugin.cpython-312.pyc b/plugins/analytics/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..222bbb8 Binary files /dev/null and b/plugins/analytics/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/analytics/plugin.py b/plugins/analytics/plugin.py new file mode 100644 index 0000000..e8491bb --- /dev/null +++ b/plugins/analytics/plugin.py @@ -0,0 +1,525 @@ +# Description: Analytics and monitoring system for EU-Utility +# Privacy-focused usage tracking and performance monitoring + +""" +EU-Utility Analytics System + +Privacy-focused analytics with opt-in tracking: +- Feature usage statistics +- Performance monitoring (FPS, memory, CPU) +- Error reporting +- Health checks + +All data is stored locally by default. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QCheckBox, QProgressBar, QTableWidget, + QTableWidgetItem, QTabWidget, QGroupBox +) +from PyQt6.QtCore import QTimer, pyqtSignal, Qt +from plugins.base_plugin import BasePlugin +from datetime import datetime, timedelta +import json +import os +import psutil +import time + + +class AnalyticsPlugin(BasePlugin): + """ + Analytics and monitoring dashboard for EU-Utility. + + Tracks: + - Feature usage (opt-in) + - System performance + - Error occurrences + - Health status + """ + + name = "Analytics" + version = "1.0.0" + author = "LemonNexus" + description = "Usage analytics and performance monitoring" + icon = "bar-chart" + + def initialize(self): + """Initialize analytics system.""" + # Load settings + self.enabled = self.load_data("enabled", False) + self.track_performance = self.load_data("track_performance", True) + self.track_usage = self.load_data("track_usage", False) + + # Data storage + self.usage_data = self.load_data("usage", {}) + self.performance_data = self.load_data("performance", []) + self.error_data = self.load_data("errors", []) + + # Performance tracking + self.start_time = time.time() + self.session_events = [] + + # Setup timers + if self.track_performance: + self._setup_performance_monitoring() + + def _setup_performance_monitoring(self): + """Setup periodic performance monitoring.""" + self.performance_timer = QTimer() + self.performance_timer.timeout.connect(self._record_performance) + self.performance_timer.start(30000) # Every 30 seconds + + # Initial record + self._record_performance() + + def _record_performance(self): + """Record current system performance.""" + try: + # Get system stats + cpu_percent = psutil.cpu_percent(interval=1) + memory = psutil.virtual_memory() + + # Get process info + process = psutil.Process() + process_memory = process.memory_info().rss / 1024 / 1024 # MB + + record = { + 'timestamp': datetime.now().isoformat(), + 'cpu_percent': cpu_percent, + 'memory_percent': memory.percent, + 'memory_used_mb': process_memory, + 'uptime_seconds': time.time() - self.start_time + } + + self.performance_data.append(record) + + # Keep only last 1000 records + if len(self.performance_data) > 1000: + self.performance_data = self.performance_data[-1000:] + + self.save_data("performance", self.performance_data) + + except Exception as e: + self.log_error(f"Performance recording failed: {e}") + + def record_event(self, event_type, details=None): + """Record a usage event (if tracking enabled).""" + if not self.track_usage: + return + + event = { + 'type': event_type, + 'timestamp': datetime.now().isoformat(), + 'details': details or {} + } + + self.session_events.append(event) + + # Update usage stats + if event_type not in self.usage_data: + self.usage_data[event_type] = {'count': 0, 'last_used': None} + + self.usage_data[event_type]['count'] += 1 + self.usage_data[event_type]['last_used'] = datetime.now().isoformat() + self.save_data("usage", self.usage_data) + + def record_error(self, error_message, context=None): + """Record an error occurrence.""" + error = { + 'message': str(error_message), + 'timestamp': datetime.now().isoformat(), + 'context': context or {}, + 'session_uptime': time.time() - self.start_time + } + + self.error_data.append(error) + + # Keep only last 100 errors + if len(self.error_data) > 100: + self.error_data = self.error_data[-100:] + + self.save_data("errors", self.error_data) + + def get_ui(self): + """Create analytics dashboard UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(15) + + # Title + title = QLabel("Analytics & Monitoring") + title.setStyleSheet("font-size: 20px; font-weight: bold; color: #4a9eff;") + layout.addWidget(title) + + # Tabs + tabs = QTabWidget() + + # Overview tab + tabs.addTab(self._create_overview_tab(), "Overview") + + # Performance tab + tabs.addTab(self._create_performance_tab(), "Performance") + + # Usage tab + tabs.addTab(self._create_usage_tab(), "Usage") + + # Errors tab + tabs.addTab(self._create_errors_tab(), "Errors") + + # Settings tab + tabs.addTab(self._create_settings_tab(), "Settings") + + layout.addWidget(tabs) + + # Refresh button + refresh_btn = QPushButton("Refresh Data") + refresh_btn.clicked.connect(self._refresh_all) + layout.addWidget(refresh_btn) + + return widget + + def _create_overview_tab(self): + """Create overview dashboard.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # System health + health_group = QGroupBox("System Health") + health_layout = QVBoxLayout(health_group) + + self.health_status = QLabel("Checking...") + self.health_status.setStyleSheet("font-size: 16px; font-weight: bold;") + health_layout.addWidget(self.health_status) + + # Health metrics + self.cpu_label = QLabel("CPU: --") + self.memory_label = QLabel("Memory: --") + self.uptime_label = QLabel("Uptime: --") + + health_layout.addWidget(self.cpu_label) + health_layout.addWidget(self.memory_label) + health_layout.addWidget(self.uptime_label) + + layout.addWidget(health_group) + + # Session stats + stats_group = QGroupBox("Session Statistics") + stats_layout = QVBoxLayout(stats_group) + + self.events_label = QLabel(f"Events recorded: {len(self.session_events)}") + self.errors_label = QLabel(f"Errors: {len(self.error_data)}") + self.plugins_label = QLabel(f"Active plugins: --") + + stats_layout.addWidget(self.events_label) + stats_layout.addWidget(self.errors_label) + stats_layout.addWidget(self.plugins_label) + + layout.addWidget(stats_group) + + layout.addStretch() + + # Update immediately + self._update_overview() + + return tab + + def _create_performance_tab(self): + """Create performance monitoring tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Current stats + current_group = QGroupBox("Current Performance") + current_layout = QVBoxLayout(current_group) + + self.perf_cpu = QLabel("CPU Usage: --") + self.perf_memory = QLabel("Memory Usage: --") + self.perf_process = QLabel("Process Memory: --") + + current_layout.addWidget(self.perf_cpu) + current_layout.addWidget(self.perf_memory) + current_layout.addWidget(self.perf_process) + + layout.addWidget(current_group) + + # Historical data + history_group = QGroupBox("Performance History (Last Hour)") + history_layout = QVBoxLayout(history_group) + + self.perf_table = QTableWidget() + self.perf_table.setColumnCount(4) + self.perf_table.setHorizontalHeaderLabels(["Time", "CPU %", "Memory %", "Process MB"]) + self.perf_table.horizontalHeader().setStretchLastSection(True) + + history_layout.addWidget(self.perf_table) + layout.addWidget(history_group) + + layout.addStretch() + + self._update_performance_tab() + + return tab + + def _create_usage_tab(self): + """Create usage statistics tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Usage table + self.usage_table = QTableWidget() + self.usage_table.setColumnCount(3) + self.usage_table.setHorizontalHeaderLabels(["Feature", "Usage Count", "Last Used"]) + self.usage_table.horizontalHeader().setStretchLastSection(True) + + layout.addWidget(self.usage_table) + + # Update data + self._update_usage_tab() + + return tab + + def _create_errors_tab(self): + """Create error log tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Error table + self.error_table = QTableWidget() + self.error_table.setColumnCount(3) + self.error_table.setHorizontalHeaderLabels(["Time", "Error", "Context"]) + self.error_table.horizontalHeader().setStretchLastSection(True) + + layout.addWidget(self.error_table) + + # Clear button + clear_btn = QPushButton("Clear Error Log") + clear_btn.clicked.connect(self._clear_errors) + layout.addWidget(clear_btn) + + self._update_errors_tab() + + return tab + + def _create_settings_tab(self): + """Create analytics settings tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Privacy notice + privacy = QLabel( + "🔒 Privacy Notice:\n" + "All analytics data is stored locally on your machine. " + "No data is sent to external servers unless you explicitly configure it." + ) + privacy.setStyleSheet("background-color: #2a3a40; padding: 10px; border-radius: 4px;") + privacy.setWordWrap(True) + layout.addWidget(privacy) + + # Enable analytics + self.enable_checkbox = QCheckBox("Enable Analytics") + self.enable_checkbox.setChecked(self.enabled) + self.enable_checkbox.toggled.connect(self._on_enable_changed) + layout.addWidget(self.enable_checkbox) + + # Performance tracking + self.perf_checkbox = QCheckBox("Track System Performance") + self.perf_checkbox.setChecked(self.track_performance) + self.perf_checkbox.toggled.connect(self._on_perf_changed) + layout.addWidget(self.perf_checkbox) + + # Usage tracking + self.usage_checkbox = QCheckBox("Track Feature Usage (Opt-in)") + self.usage_checkbox.setChecked(self.track_usage) + self.usage_checkbox.toggled.connect(self._on_usage_changed) + layout.addWidget(self.usage_checkbox) + + # Data management + layout.addWidget(QLabel("Data Management:")) + + export_btn = QPushButton("Export Analytics Data") + export_btn.clicked.connect(self._export_data) + layout.addWidget(export_btn) + + clear_all_btn = QPushButton("Clear All Analytics Data") + clear_all_btn.setStyleSheet("color: #f44336;") + clear_all_btn.clicked.connect(self._clear_all_data) + layout.addWidget(clear_all_btn) + + layout.addStretch() + + return tab + + def _update_overview(self): + """Update overview tab.""" + try: + # Get current stats + cpu = psutil.cpu_percent(interval=0.5) + memory = psutil.virtual_memory() + + # Health status + if cpu < 50 and memory.percent < 80: + status = "✓ Healthy" + color = "#4caf50" + elif cpu < 80 and memory.percent < 90: + status = "⚠ Warning" + color = "#ff9800" + else: + status = "✗ Critical" + color = "#f44336" + + self.health_status.setText(status) + self.health_status.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {color};") + + self.cpu_label.setText(f"CPU: {cpu:.1f}%") + self.memory_label.setText(f"Memory: {memory.percent:.1f}%") + + # Uptime + uptime = time.time() - self.start_time + hours = int(uptime // 3600) + minutes = int((uptime % 3600) // 60) + self.uptime_label.setText(f"Uptime: {hours}h {minutes}m") + + # Stats + self.events_label.setText(f"Events recorded: {len(self.session_events)}") + self.errors_label.setText(f"Total errors: {len(self.error_data)}") + + except Exception as e: + self.log_error(f"Overview update failed: {e}") + + def _update_performance_tab(self): + """Update performance tab.""" + try: + # Current stats + cpu = psutil.cpu_percent(interval=0.5) + memory = psutil.virtual_memory() + process = psutil.Process() + process_mem = process.memory_info().rss / 1024 / 1024 + + self.perf_cpu.setText(f"CPU Usage: {cpu:.1f}%") + self.perf_memory.setText(f"Memory Usage: {memory.percent:.1f}%") + self.perf_process.setText(f"Process Memory: {process_mem:.1f} MB") + + # Historical data (last 20 records) + recent_data = self.performance_data[-20:] + self.perf_table.setRowCount(len(recent_data)) + + for i, record in enumerate(reversed(recent_data)): + time_str = record['timestamp'][11:19] # HH:MM:SS + self.perf_table.setItem(i, 0, QTableWidgetItem(time_str)) + self.perf_table.setItem(i, 1, QTableWidgetItem(f"{record['cpu_percent']:.1f}%")) + self.perf_table.setItem(i, 2, QTableWidgetItem(f"{record['memory_percent']:.1f}%")) + self.perf_table.setItem(i, 3, QTableWidgetItem(f"{record['memory_used_mb']:.1f}")) + + except Exception as e: + self.log_error(f"Performance tab update failed: {e}") + + def _update_usage_tab(self): + """Update usage tab.""" + self.usage_table.setRowCount(len(self.usage_data)) + + for i, (feature, data) in enumerate(sorted(self.usage_data.items())): + self.usage_table.setItem(i, 0, QTableWidgetItem(feature)) + self.usage_table.setItem(i, 1, QTableWidgetItem(str(data['count']))) + + last_used = data.get('last_used', 'Never') + if last_used and last_used != 'Never': + last_used = last_used[:16].replace('T', ' ') # Format datetime + self.usage_table.setItem(i, 2, QTableWidgetItem(last_used)) + + def _update_errors_tab(self): + """Update errors tab.""" + self.error_table.setRowCount(len(self.error_data)) + + for i, error in enumerate(reversed(self.error_data[-50:])): # Last 50 errors + time_str = error['timestamp'][11:19] + self.error_table.setItem(i, 0, QTableWidgetItem(time_str)) + self.error_table.setItem(i, 1, QTableWidgetItem(error['message'][:50])) + self.error_table.setItem(i, 2, QTableWidgetItem(str(error.get('context', ''))[:50])) + + def _refresh_all(self): + """Refresh all tabs.""" + self._update_overview() + self._update_performance_tab() + self._update_usage_tab() + self._update_errors_tab() + + def _on_enable_changed(self, checked): + """Handle analytics enable toggle.""" + self.enabled = checked + self.save_data("enabled", checked) + + if checked and self.track_performance: + self._setup_performance_monitoring() + elif not checked and hasattr(self, 'performance_timer'): + self.performance_timer.stop() + + def _on_perf_changed(self, checked): + """Handle performance tracking toggle.""" + self.track_performance = checked + self.save_data("track_performance", checked) + + if checked and self.enabled: + self._setup_performance_monitoring() + elif hasattr(self, 'performance_timer'): + self.performance_timer.stop() + + def _on_usage_changed(self, checked): + """Handle usage tracking toggle.""" + self.track_usage = checked + self.save_data("track_usage", checked) + + def _export_data(self): + """Export analytics data.""" + data = { + 'exported_at': datetime.now().isoformat(), + 'usage': self.usage_data, + 'performance_samples': len(self.performance_data), + 'errors': len(self.error_data) + } + + # Save to file + export_path = os.path.expanduser('~/.eu-utility/analytics_export.json') + os.makedirs(os.path.dirname(export_path), exist_ok=True) + + with open(export_path, 'w') as f: + json.dump(data, f, indent=2) + + self.notify_info("Export Complete", f"Data exported to:\n{export_path}") + + def _clear_all_data(self): + """Clear all analytics data.""" + self.usage_data = {} + self.performance_data = [] + self.error_data = [] + self.session_events = [] + + self.save_data("usage", {}) + self.save_data("performance", []) + self.save_data("errors", []) + + self._refresh_all() + self.notify_info("Data Cleared", "All analytics data has been cleared.") + + def _clear_errors(self): + """Clear error log.""" + self.error_data = [] + self.save_data("errors", []) + self._update_errors_tab() + + def on_show(self): + """Update when tab shown.""" + self._refresh_all() + + def shutdown(self): + """Cleanup on shutdown.""" + if hasattr(self, 'performance_timer'): + self.performance_timer.stop() + + # Record final stats + if self.enabled: + self.save_data("final_session", { + 'session_duration': time.time() - self.start_time, + 'events_recorded': len(self.session_events), + 'timestamp': datetime.now().isoformat() + }) diff --git a/plugins/auction_tracker/__init__.py b/plugins/auction_tracker/__init__.py new file mode 100644 index 0000000..2b39bff --- /dev/null +++ b/plugins/auction_tracker/__init__.py @@ -0,0 +1,7 @@ +""" +Auction Tracker Plugin +""" + +from .plugin import AuctionTrackerPlugin + +__all__ = ["AuctionTrackerPlugin"] diff --git a/plugins/auction_tracker/__pycache__/__init__.cpython-312.pyc b/plugins/auction_tracker/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e30b211 Binary files /dev/null and b/plugins/auction_tracker/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/auction_tracker/__pycache__/plugin.cpython-312.pyc b/plugins/auction_tracker/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..a78f7e9 Binary files /dev/null and b/plugins/auction_tracker/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/auction_tracker/plugin.py b/plugins/auction_tracker/plugin.py new file mode 100644 index 0000000..78b945d --- /dev/null +++ b/plugins/auction_tracker/plugin.py @@ -0,0 +1,261 @@ +""" +EU-Utility - Auction Tracker Plugin + +Track auction prices and market trends. +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path +from collections import defaultdict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QLineEdit, QComboBox, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin +from core.icon_manager import get_icon_manager +from PyQt6.QtGui import QIcon + + +class AuctionTrackerPlugin(BasePlugin): + """Track auction prices and analyze market trends.""" + + name = "Auction Tracker" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track prices, markups, and market trends" + hotkey = "ctrl+shift+a" + + def initialize(self): + """Setup auction tracker.""" + self.data_file = Path("data/auction_tracker.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.price_history = defaultdict(list) + self.watchlist = [] + + self._load_data() + + def _load_data(self): + """Load auction data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.price_history = defaultdict(list, data.get('prices', {})) + self.watchlist = data.get('watchlist', []) + except: + pass + + def _save_data(self): + """Save auction data.""" + with open(self.data_file, 'w') as f: + json.dump({ + 'prices': dict(self.price_history), + 'watchlist': self.watchlist + }, f, indent=2) + + def get_ui(self): + """Create auction tracker UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Get icon manager + icon_mgr = get_icon_manager() + + # Title with icon + title_layout = QHBoxLayout() + + title_icon = QLabel() + icon_pixmap = icon_mgr.get_pixmap('trending-up', size=20) + title_icon.setPixmap(icon_pixmap) + title_icon.setFixedSize(20, 20) + title_layout.addWidget(title_icon) + + title = QLabel("Auction Tracker") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + title_layout.addWidget(title) + title_layout.addStretch() + + layout.addLayout(title_layout) + + # Search + search_layout = QHBoxLayout() + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search item...") + self.search_input.setStyleSheet(""" + QLineEdit { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 4px; + padding: 8px; + } + """) + search_layout.addWidget(self.search_input) + + search_btn = QPushButton() + search_pixmap = icon_mgr.get_pixmap('search', size=16) + search_btn.setIcon(QIcon(search_pixmap)) + search_btn.setIconSize(Qt.QSize(16, 16)) + search_btn.setFixedSize(32, 32) + search_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + border: none; + border-radius: 4px; + } + QPushButton:hover { + background-color: #ffa060; + } + """) + search_btn.clicked.connect(self._search_item) + search_layout.addWidget(search_btn) + + layout.addLayout(search_layout) + + # Price table + self.price_table = QTableWidget() + self.price_table.setColumnCount(6) + self.price_table.setHorizontalHeaderLabels(["Item", "Bid", "Buyout", "Markup", "Trend", "Time"]) + self.price_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 8px; + font-weight: bold; + font-size: 11px; + } + """) + self.price_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.price_table) + + # Quick scan + scan_btn = QPushButton("Scan Auction Window") + scan_btn.setStyleSheet(""" + QPushButton { + background-color: #ffc107; + color: black; + padding: 12px; + border: none; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #ffd54f; + } + """) + scan_btn.clicked.connect(self._scan_auction) + layout.addWidget(scan_btn) + + # Sample data + self._add_sample_data() + + layout.addStretch() + return widget + + def _add_sample_data(self): + """Add sample auction data.""" + sample_items = [ + {"name": "Nanocube", "bid": 304.00, "buyout": 304.00, "markup": 101.33}, + {"name": "Aakas Plating", "bid": 154.00, "buyout": 159.00, "markup": 102.67}, + {"name": "Wenrex Ingot", "bid": 111.00, "buyout": 118.00, "markup": 108.82}, + ] + + self.price_table.setRowCount(len(sample_items)) + for i, item in enumerate(sample_items): + self.price_table.setItem(i, 0, QTableWidgetItem(item['name'])) + self.price_table.setItem(i, 1, QTableWidgetItem(f"{item['bid']:.2f}")) + self.price_table.setItem(i, 2, QTableWidgetItem(f"{item['buyout']:.2f}")) + + markup_item = QTableWidgetItem(f"{item['markup']:.2f}%") + if item['markup'] > 105: + markup_item.setForeground(Qt.GlobalColor.red) + elif item['markup'] < 102: + markup_item.setForeground(Qt.GlobalColor.green) + self.price_table.setItem(i, 3, markup_item) + + self.price_table.setItem(i, 4, QTableWidgetItem("→")) + self.price_table.setItem(i, 5, QTableWidgetItem("2m ago")) + + def _search_item(self): + """Search for item price history.""" + query = self.search_input.text().lower() + # TODO: Implement search + + def _scan_auction(self): + """Scan auction window with OCR.""" + # TODO: Implement OCR scanning + pass + + def record_price(self, item_name, bid, buyout, tt_value=None): + """Record a price observation.""" + entry = { + 'time': datetime.now().isoformat(), + 'bid': bid, + 'buyout': buyout, + 'tt': tt_value, + 'markup': (buyout / tt_value * 100) if tt_value else None + } + + self.price_history[item_name].append(entry) + + # Keep last 100 entries per item + if len(self.price_history[item_name]) > 100: + self.price_history[item_name] = self.price_history[item_name][-100:] + + self._save_data() + + def get_price_trend(self, item_name, days=7): + """Get price trend for an item.""" + history = self.price_history.get(item_name, []) + if not history: + return None + + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + recent = [h for h in history if h['time'] > cutoff] + + if len(recent) < 2: + return None + + prices = [h['buyout'] for h in recent] + return { + 'current': prices[-1], + 'average': sum(prices) / len(prices), + 'min': min(prices), + 'max': max(prices), + 'trend': 'up' if prices[-1] > prices[0] else 'down' if prices[-1] < prices[0] else 'stable' + } + + def get_deals(self, max_markup=102.0): + """Find items below market price.""" + deals = [] + for item_name, history in self.price_history.items(): + if not history: + continue + + latest = history[-1] + markup = latest.get('markup') + + if markup and markup <= max_markup: + deals.append({ + 'item': item_name, + 'price': latest['buyout'], + 'markup': markup + }) + + return sorted(deals, key=lambda x: x['markup']) diff --git a/plugins/auto_screenshot/__init__.py b/plugins/auto_screenshot/__init__.py new file mode 100644 index 0000000..ab058c2 --- /dev/null +++ b/plugins/auto_screenshot/__init__.py @@ -0,0 +1,10 @@ +""" +Auto-Screenshot Plugin for EU-Utility + +Automatically capture screenshots on Global, HOF, or other +significant game events. +""" + +from .plugin import AutoScreenshotPlugin + +__all__ = ['AutoScreenshotPlugin'] diff --git a/plugins/auto_screenshot/__pycache__/__init__.cpython-312.pyc b/plugins/auto_screenshot/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ce23582 Binary files /dev/null and b/plugins/auto_screenshot/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/auto_screenshot/__pycache__/plugin.cpython-312.pyc b/plugins/auto_screenshot/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..c9619c0 Binary files /dev/null and b/plugins/auto_screenshot/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/auto_screenshot/plugin.py b/plugins/auto_screenshot/plugin.py new file mode 100644 index 0000000..58974c5 --- /dev/null +++ b/plugins/auto_screenshot/plugin.py @@ -0,0 +1,735 @@ +""" +EU-Utility - Auto-Screenshot Plugin + +Automatically capture screenshots on Global, HOF, or other +significant game events. Perfect for sharing achievements! +""" + +import os +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QCheckBox, QSpinBox, QGroupBox, QFileDialog, QLineEdit, + QComboBox, QListWidget, QListWidgetItem, QTabWidget +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject +from PyQt6.QtGui import QColor + +from plugins.base_plugin import BasePlugin +from core.event_bus import GlobalEvent, LootEvent, SkillGainEvent + + +@dataclass +class ScreenshotRule: + """Rule for when to take a screenshot.""" + event_type: str # 'global', 'hof', 'ath', 'skill', 'loot_value', 'custom' + min_value: float = 0.0 # For value-based triggers (PED, skill gain, etc.) + enabled: bool = True + play_sound: bool = True + show_notification: bool = True + custom_pattern: str = "" # For custom log patterns + + +class AutoScreenshotSignals(QObject): + """Signals for thread-safe UI updates.""" + screenshot_taken = pyqtSignal(str) # Path to screenshot + event_detected = pyqtSignal(str) # Event description + + +class AutoScreenshotPlugin(BasePlugin): + """ + Plugin for automatic screenshots on game events. + + Features: + - Screenshot on Global/HOF/ATH + - Screenshot on rare loot + - Screenshot on significant skill gains + - Configurable capture delay + - Custom filename patterns + - Notification and sound options + """ + + name = "Auto-Screenshot" + version = "1.0.0" + author = "EU-Utility" + description = "Capture screenshots on Global/HOF and other events" + icon = "📸" + hotkey = "ctrl+shift+c" + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + + # Settings + self.enabled = self.get_config('enabled', True) + self.capture_delay_ms = self.get_config('capture_delay_ms', 500) + self.save_directory = self.get_config('save_directory', + str(Path.home() / "Documents" / "Entropia Universe" / "Screenshots" / "Globals")) + + # Filename pattern + self.filename_pattern = self.get_config('filename_pattern', + "{event_type}_{timestamp}_{player}") + + # Screenshot rules + self.rules: Dict[str, ScreenshotRule] = {} + self._load_rules() + + # Statistics + self.screenshots_taken = 0 + self.events_captured: List[Dict] = [] + + # UI references + self._ui = None + self.events_list = None + self.status_label = None + self.dir_label = None + + # Signals + self.signals = AutoScreenshotSignals() + self.signals.screenshot_taken.connect(self._on_screenshot_saved) + self.signals.event_detected.connect(self._on_event_logged) + + # Event subscriptions + self._subscriptions: List[str] = [] + + # Pending screenshot timer + self._pending_screenshot = None + self._pending_timer = None + + def initialize(self) -> None: + """Initialize plugin.""" + self.log_info("Initializing Auto-Screenshot") + + # Ensure save directory exists + Path(self.save_directory).mkdir(parents=True, exist_ok=True) + + # Subscribe to events + if self.enabled: + self._subscribe_to_events() + + self.log_info(f"Auto-Screenshot initialized (enabled={self.enabled})") + + def _subscribe_to_events(self) -> None: + """Subscribe to game events.""" + # Global/HOF events + self._subscriptions.append( + self.subscribe_typed(GlobalEvent, self._on_global_event) + ) + + # Loot events (for high-value loot) + self._subscriptions.append( + self.subscribe_typed(LootEvent, self._on_loot_event) + ) + + # Skill gain events + self._subscriptions.append( + self.subscribe_typed(SkillGainEvent, self._on_skill_event) + ) + + def get_ui(self) -> QWidget: + """Return the plugin's UI widget.""" + if self._ui is None: + self._ui = self._create_ui() + return self._ui + + def _create_ui(self) -> QWidget: + """Create the plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(12) + layout.setContentsMargins(16, 16, 16, 16) + + # Header + header = QLabel("📸 Auto-Screenshot") + header.setStyleSheet(""" + font-size: 18px; + font-weight: bold; + color: white; + padding-bottom: 8px; + """) + layout.addWidget(header) + + # Status + self.status_label = QLabel(f"Status: {'Enabled' if self.enabled else 'Disabled'} | Captured: {self.screenshots_taken}") + self.status_label.setStyleSheet("color: rgba(255, 255, 255, 150);") + layout.addWidget(self.status_label) + + # Create tabs + tabs = QTabWidget() + tabs.setStyleSheet(""" + QTabWidget::pane { + background-color: rgba(30, 35, 45, 150); + border: 1px solid rgba(100, 150, 200, 50); + border-radius: 8px; + } + QTabBar::tab { + background-color: rgba(50, 60, 75, 200); + color: white; + padding: 8px 16px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + QTabBar::tab:selected { + background-color: rgba(100, 150, 200, 200); + } + """) + + # === Rules Tab === + rules_widget = QWidget() + rules_layout = QVBoxLayout(rules_widget) + rules_layout.setContentsMargins(12, 12, 12, 12) + + rules_header = QLabel("Capture Rules") + rules_header.setStyleSheet("font-weight: bold; color: white;") + rules_layout.addWidget(rules_header) + + # Global checkbox + self.global_checkbox = QCheckBox("Capture on Global (any value)") + self.global_checkbox.setChecked(self.rules.get('global', ScreenshotRule('global')).enabled) + self.global_checkbox.setStyleSheet("color: white;") + self.global_checkbox.stateChanged.connect(lambda: self._update_rule('global', self.global_checkbox.isChecked())) + rules_layout.addWidget(self.global_checkbox) + + # HOF checkbox + self.hof_checkbox = QCheckBox("Capture on HOF (50+ PED)") + self.hof_checkbox.setChecked(self.rules.get('hof', ScreenshotRule('hof', min_value=50)).enabled) + self.hof_checkbox.setStyleSheet("color: white;") + self.hof_checkbox.stateChanged.connect(lambda: self._update_rule('hof', self.hof_checkbox.isChecked())) + rules_layout.addWidget(self.hof_checkbox) + + # ATH checkbox + self.ath_checkbox = QCheckBox("Capture on ATH (All-Time High)") + self.ath_checkbox.setChecked(self.rules.get('ath', ScreenshotRule('ath')).enabled) + self.ath_checkbox.setStyleSheet("color: white;") + self.ath_checkbox.stateChanged.connect(lambda: self._update_rule('ath', self.ath_checkbox.isChecked())) + rules_layout.addWidget(self.ath_checkbox) + + # Discovery checkbox + self.discovery_checkbox = QCheckBox("Capture on Discovery") + self.discovery_checkbox.setChecked(self.rules.get('discovery', ScreenshotRule('discovery')).enabled) + self.discovery_checkbox.setStyleSheet("color: white;") + self.discovery_checkbox.stateChanged.connect(lambda: self._update_rule('discovery', self.discovery_checkbox.isChecked())) + rules_layout.addWidget(self.discovery_checkbox) + + # High value loot + loot_layout = QHBoxLayout() + self.loot_checkbox = QCheckBox("Capture on loot above") + self.loot_checkbox.setChecked(self.rules.get('loot_value', ScreenshotRule('loot_value', min_value=100)).enabled) + self.loot_checkbox.setStyleSheet("color: white;") + self.loot_checkbox.stateChanged.connect(lambda: self._update_rule('loot_value', self.loot_checkbox.isChecked())) + loot_layout.addWidget(self.loot_checkbox) + + self.loot_spin = QSpinBox() + self.loot_spin.setRange(1, 10000) + self.loot_spin.setValue(int(self.rules.get('loot_value', ScreenshotRule('loot_value', min_value=100)).min_value)) + self.loot_spin.setSuffix(" PED") + self.loot_spin.setStyleSheet(self._spinbox_style()) + loot_layout.addWidget(self.loot_spin) + loot_layout.addStretch() + rules_layout.addLayout(loot_layout) + + # Skill gain + skill_layout = QHBoxLayout() + self.skill_checkbox = QCheckBox("Capture on skill gain above") + self.skill_checkbox.setChecked(self.rules.get('skill', ScreenshotRule('skill', min_value=0.1)).enabled) + self.skill_checkbox.setStyleSheet("color: white;") + self.skill_checkbox.stateChanged.connect(lambda: self._update_rule('skill', self.skill_checkbox.isChecked())) + skill_layout.addWidget(self.skill_checkbox) + + self.skill_spin = QSpinBox() + self.skill_spin.setRange(1, 1000) + self.skill_spin.setValue(int(self.rules.get('skill', ScreenshotRule('skill', min_value=0.1)).min_value * 100)) + self.skill_spin.setSuffix(" points (x0.01)") + self.skill_spin.setStyleSheet(self._spinbox_style()) + skill_layout.addWidget(self.skill_spin) + skill_layout.addStretch() + rules_layout.addLayout(skill_layout) + + # Sound/notification options + options_group = QGroupBox("Notification Options") + options_layout = QVBoxLayout(options_group) + + self.sound_checkbox = QCheckBox("Play sound on capture") + self.sound_checkbox.setChecked(self.get_config('play_sound', True)) + self.sound_checkbox.setStyleSheet("color: white;") + options_layout.addWidget(self.sound_checkbox) + + self.notification_checkbox = QCheckBox("Show notification on capture") + self.notification_checkbox.setChecked(self.get_config('show_notification', True)) + self.notification_checkbox.setStyleSheet("color: white;") + options_layout.addWidget(self.notification_checkbox) + + rules_layout.addWidget(options_group) + rules_layout.addStretch() + + tabs.addTab(rules_widget, "📋 Rules") + + # === History Tab === + history_widget = QWidget() + history_layout = QVBoxLayout(history_widget) + history_layout.setContentsMargins(12, 12, 12, 12) + + history_header = QLabel("Recent Captures") + history_header.setStyleSheet("font-weight: bold; color: white;") + history_layout.addWidget(history_header) + + self.events_list = QListWidget() + self.events_list.setStyleSheet(""" + QListWidget { + background-color: rgba(30, 35, 45, 150); + border: 1px solid rgba(100, 150, 200, 50); + border-radius: 8px; + color: white; + } + QListWidget::item { + padding: 8px; + border-bottom: 1px solid rgba(100, 150, 200, 30); + } + QListWidget::item:selected { + background-color: rgba(100, 150, 200, 100); + } + """) + history_layout.addWidget(self.events_list) + + # History buttons + history_btn_layout = QHBoxLayout() + + open_folder_btn = QPushButton("📁 Open Folder") + open_folder_btn.setStyleSheet(self._button_style("#2196f3")) + open_folder_btn.clicked.connect(self._open_screenshots_folder) + history_btn_layout.addWidget(open_folder_btn) + + clear_history_btn = QPushButton("🧹 Clear History") + clear_history_btn.setStyleSheet(self._button_style()) + clear_history_btn.clicked.connect(self._clear_history) + history_btn_layout.addWidget(clear_history_btn) + + history_btn_layout.addStretch() + history_layout.addLayout(history_btn_layout) + + tabs.addTab(history_widget, "📜 History") + + # === Settings Tab === + settings_widget = QWidget() + settings_layout = QVBoxLayout(settings_widget) + settings_layout.setContentsMargins(12, 12, 12, 12) + + settings_header = QLabel("Capture Settings") + settings_header.setStyleSheet("font-weight: bold; color: white;") + settings_layout.addWidget(settings_header) + + # Master enable + self.enable_checkbox = QCheckBox("Enable Auto-Screenshot") + self.enable_checkbox.setChecked(self.enabled) + self.enable_checkbox.setStyleSheet("color: #4caf50; font-weight: bold;") + self.enable_checkbox.stateChanged.connect(self._toggle_enabled) + settings_layout.addWidget(self.enable_checkbox) + + # Capture delay + delay_layout = QHBoxLayout() + delay_label = QLabel("Capture Delay:") + delay_label.setStyleSheet("color: white;") + delay_layout.addWidget(delay_label) + + self.delay_spin = QSpinBox() + self.delay_spin.setRange(0, 5000) + self.delay_spin.setValue(self.capture_delay_ms) + self.delay_spin.setSuffix(" ms") + self.delay_spin.setSingleStep(100) + self.delay_spin.setStyleSheet(self._spinbox_style()) + delay_layout.addWidget(self.delay_spin) + + delay_info = QLabel("(time to hide overlay)") + delay_info.setStyleSheet("color: rgba(255, 255, 255, 100); font-size: 11px;") + delay_layout.addWidget(delay_info) + delay_layout.addStretch() + settings_layout.addLayout(delay_layout) + + # Save directory + dir_group = QGroupBox("Save Location") + dir_layout = QVBoxLayout(dir_group) + + dir_row = QHBoxLayout() + self.dir_label = QLabel(self.save_directory) + self.dir_label.setStyleSheet("color: white;") + self.dir_label.setWordWrap(True) + dir_row.addWidget(self.dir_label, 1) + + change_dir_btn = QPushButton("📁 Change") + change_dir_btn.setStyleSheet(self._button_style()) + change_dir_btn.clicked.connect(self._change_save_directory) + dir_row.addWidget(change_dir_btn) + + dir_layout.addLayout(dir_row) + settings_layout.addWidget(dir_group) + + # Filename pattern + pattern_group = QGroupBox("Filename Pattern") + pattern_layout = QVBoxLayout(pattern_group) + + pattern_info = QLabel("Available variables: {timestamp}, {event_type}, {player}, {value}") + pattern_info.setStyleSheet("color: rgba(255, 255, 255, 100); font-size: 11px;") + pattern_layout.addWidget(pattern_info) + + self.pattern_input = QLineEdit(self.filename_pattern) + self.pattern_input.setStyleSheet(self._input_style()) + pattern_layout.addWidget(self.pattern_input) + + settings_layout.addWidget(pattern_group) + settings_layout.addStretch() + + tabs.addTab(settings_widget, "⚙️ Settings") + + layout.addWidget(tabs) + + # Save button + save_btn = QPushButton("💾 Save Settings") + save_btn.setStyleSheet(self._button_style("#4caf50")) + save_btn.clicked.connect(self._save_settings) + layout.addWidget(save_btn) + + # Test button + test_btn = QPushButton("📸 Test Screenshot") + test_btn.setStyleSheet(self._button_style("#ff9800")) + test_btn.clicked.connect(lambda: self._take_screenshot("test", "TestUser", 0)) + layout.addWidget(test_btn) + + return widget + + def _button_style(self, color: str = "#607d8b") -> str: + """Generate button stylesheet.""" + return f""" + QPushButton {{ + background-color: {color}; + color: white; + border: none; + border-radius: 8px; + padding: 10px 16px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {color}dd; + }} + QPushButton:pressed {{ + background-color: {color}aa; + }} + """ + + def _input_style(self) -> str: + """Generate input stylesheet.""" + return """ + QLineEdit { + background-color: rgba(50, 60, 75, 200); + color: white; + border: 1px solid rgba(100, 150, 200, 100); + border-radius: 8px; + padding: 8px 12px; + } + """ + + def _spinbox_style(self) -> str: + """Generate spinbox stylesheet.""" + return """ + QSpinBox { + background-color: rgba(50, 60, 75, 200); + color: white; + border: 1px solid rgba(100, 150, 200, 100); + border-radius: 4px; + padding: 4px; + } + """ + + def _on_global_event(self, event: GlobalEvent) -> None: + """Handle global/HOF events.""" + if not self.enabled: + return + + event_type = event.achievement_type.lower() + + # Check if we should capture + if event_type == 'global' and self.rules.get('global', ScreenshotRule('global')).enabled: + self._schedule_screenshot('global', event.player_name, event.value) + + elif event_type in ['hof', 'hall of fame'] and self.rules.get('hof', ScreenshotRule('hof')).enabled: + if event.value >= self.rules.get('hof', ScreenshotRule('hof', min_value=50)).min_value: + self._schedule_screenshot('hof', event.player_name, event.value) + + elif event_type in ['ath', 'all time high'] and self.rules.get('ath', ScreenshotRule('ath')).enabled: + self._schedule_screenshot('ath', event.player_name, event.value) + + elif event_type == 'discovery' and self.rules.get('discovery', ScreenshotRule('discovery')).enabled: + self._schedule_screenshot('discovery', event.player_name, event.value) + + def _on_loot_event(self, event: LootEvent) -> None: + """Handle loot events.""" + if not self.enabled: + return + + rule = self.rules.get('loot_value', ScreenshotRule('loot_value', min_value=100)) + if rule.enabled and event.total_tt_value >= rule.min_value: + self._schedule_screenshot('loot', 'player', event.total_tt_value) + + def _on_skill_event(self, event: SkillGainEvent) -> None: + """Handle skill gain events.""" + if not self.enabled: + return + + rule = self.rules.get('skill', ScreenshotRule('skill', min_value=0.1)) + if rule.enabled and event.gain_amount >= rule.min_value: + self._schedule_screenshot('skill', 'player', event.gain_amount) + + def _schedule_screenshot(self, event_type: str, player_name: str, value: float) -> None: + """Schedule a screenshot with optional delay.""" + if self._pending_timer: + self._pending_timer.stop() + + self._pending_screenshot = (event_type, player_name, value) + + if self.capture_delay_ms > 0: + self._pending_timer = QTimer() + self._pending_timer.timeout.connect(lambda: self._execute_screenshot()) + self._pending_timer.setSingleShot(True) + self._pending_timer.start(self.capture_delay_ms) + else: + self._execute_screenshot() + + def _execute_screenshot(self) -> None: + """Execute the pending screenshot.""" + if not self._pending_screenshot: + return + + event_type, player_name, value = self._pending_screenshot + self._pending_screenshot = None + + self._take_screenshot(event_type, player_name, value) + + def _take_screenshot(self, event_type: str, player_name: str, value: float) -> None: + """Take and save a screenshot.""" + try: + # Generate filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = self.filename_pattern.format( + timestamp=timestamp, + event_type=event_type.upper(), + player=player_name, + value=f"{value:.0f}" + ) + filename = f"{filename}.png" + + # Clean filename + filename = "".join(c for c in filename if c.isalnum() or c in "._- ") + + filepath = Path(self.save_directory) / filename + + # Capture screenshot + screenshot = self.capture_screen(full_screen=True) + + # Save + screenshot.save(str(filepath), 'PNG') + + self.screenshots_taken += 1 + + # Log event + event_data = { + 'timestamp': datetime.now().isoformat(), + 'event_type': event_type, + 'player': player_name, + 'value': value, + 'filepath': str(filepath) + } + self.events_captured.append(event_data) + + # Trim history + if len(self.events_captured) > 100: + self.events_captured = self.events_captured[-100:] + + # Emit signals + self.signals.screenshot_taken.emit(str(filepath)) + self.signals.event_detected.emit(f"{event_type.upper()}: {player_name} - {value:.0f} PED") + + # Play sound if enabled + if self.sound_checkbox.isChecked(): + self.play_sound('global' if event_type in ['global', 'hof', 'ath'] else 'skill_gain') + + # Show notification if enabled + if self.notification_checkbox.isChecked(): + self.notify( + f"📸 {event_type.upper()} Captured!", + f"Saved to {filename}", + notification_type='success' + ) + + self.log_info(f"Screenshot saved: {filepath}") + + except Exception as e: + self.log_error(f"Screenshot failed: {e}") + self.notify_error("Screenshot Failed", str(e)) + + def _on_screenshot_saved(self, filepath: str) -> None: + """Handle screenshot saved event.""" + self.status_label.setText(f"Status: {'Enabled' if self.enabled else 'Disabled'} | Captured: {self.screenshots_taken}") + + def _on_event_logged(self, description: str) -> None: + """Handle event logged.""" + if self.events_list: + item = QListWidgetItem(f"[{datetime.now().strftime('%H:%M:%S')}] {description}") + item.setForeground(QColor("white")) + self.events_list.insertItem(0, item) + + # Trim list + while self.events_list.count() > 50: + self.events_list.takeItem(self.events_list.count() - 1) + + def _update_rule(self, rule_name: str, enabled: bool) -> None: + """Update a rule's enabled state.""" + if rule_name not in self.rules: + # Create default rule + defaults = { + 'global': ScreenshotRule('global'), + 'hof': ScreenshotRule('hof', min_value=50), + 'ath': ScreenshotRule('ath'), + 'discovery': ScreenshotRule('discovery'), + 'loot_value': ScreenshotRule('loot_value', min_value=100), + 'skill': ScreenshotRule('skill', min_value=0.1) + } + self.rules[rule_name] = defaults.get(rule_name, ScreenshotRule(rule_name)) + + self.rules[rule_name].enabled = enabled + self._save_rules() + + def _toggle_enabled(self, state) -> None: + """Toggle plugin enabled state.""" + self.enabled = state == Qt.CheckState.Checked.value + + if self.enabled: + self._subscribe_to_events() + self.status_label.setText(f"Status: Enabled | Captured: {self.screenshots_taken}") + self.status_label.setStyleSheet("color: #4caf50;") + else: + # Unsubscribe + for sub_id in self._subscriptions: + self.unsubscribe_typed(sub_id) + self._subscriptions.clear() + self.status_label.setText(f"Status: Disabled | Captured: {self.screenshots_taken}") + self.status_label.setStyleSheet("color: #f44336;") + + def _change_save_directory(self) -> None: + """Change the save directory.""" + new_dir = QFileDialog.getExistingDirectory( + self._ui, + "Select Screenshot Directory", + self.save_directory + ) + + if new_dir: + self.save_directory = new_dir + Path(self.save_directory).mkdir(parents=True, exist_ok=True) + if self.dir_label: + self.dir_label.setText(new_dir) + + def _open_screenshots_folder(self) -> None: + """Open the screenshots folder.""" + import subprocess + import platform + + try: + if platform.system() == 'Windows': + subprocess.run(['explorer', self.save_directory], check=True) + elif platform.system() == 'Darwin': + subprocess.run(['open', self.save_directory], check=True) + else: + subprocess.run(['xdg-open', self.save_directory], check=True) + except Exception as e: + self.log_error(f"Failed to open folder: {e}") + + def _clear_history(self) -> None: + """Clear the events history.""" + self.events_captured.clear() + if self.events_list: + self.events_list.clear() + + def _save_settings(self) -> None: + """Save all settings.""" + self.capture_delay_ms = self.delay_spin.value() + self.filename_pattern = self.pattern_input.text() + + # Update rules with values + if 'loot_value' in self.rules: + self.rules['loot_value'].min_value = self.loot_spin.value() + if 'skill' in self.rules: + self.rules['skill'].min_value = self.skill_spin.value() / 100 + + self.set_config('enabled', self.enabled) + self.set_config('capture_delay_ms', self.capture_delay_ms) + self.set_config('save_directory', self.save_directory) + self.set_config('filename_pattern', self.filename_pattern) + self.set_config('play_sound', self.sound_checkbox.isChecked()) + self.set_config('show_notification', self.notification_checkbox.isChecked()) + + self._save_rules() + + self.notify_success("Settings Saved", "Auto-screenshot settings updated") + + def _save_rules(self) -> None: + """Save rules to storage.""" + rules_data = { + name: { + 'event_type': rule.event_type, + 'min_value': rule.min_value, + 'enabled': rule.enabled, + 'play_sound': rule.play_sound, + 'show_notification': rule.show_notification + } + for name, rule in self.rules.items() + } + self.save_data('rules', rules_data) + self.save_data('events_captured', self.events_captured) + + def _load_rules(self) -> None: + """Load rules from storage.""" + rules_data = self.load_data('rules', {}) + for name, data in rules_data.items(): + self.rules[name] = ScreenshotRule( + event_type=data['event_type'], + min_value=data.get('min_value', 0), + enabled=data.get('enabled', True), + play_sound=data.get('play_sound', True), + show_notification=data.get('show_notification', True) + ) + + # Set defaults if not loaded + defaults = { + 'global': ScreenshotRule('global'), + 'hof': ScreenshotRule('hof', min_value=50), + 'ath': ScreenshotRule('ath'), + 'discovery': ScreenshotRule('discovery'), + 'loot_value': ScreenshotRule('loot_value', min_value=100), + 'skill': ScreenshotRule('skill', min_value=0.1) + } + + for name, rule in defaults.items(): + if name not in self.rules: + self.rules[name] = rule + + self.events_captured = self.load_data('events_captured', []) + self.screenshots_taken = len(self.events_captured) + + def on_hotkey(self) -> None: + """Handle hotkey press - take manual screenshot.""" + self._take_screenshot('manual', 'player', 0) + + def shutdown(self) -> None: + """Cleanup on shutdown.""" + self._save_settings() + + if self._pending_timer: + self._pending_timer.stop() + + # Unsubscribe + for sub_id in self._subscriptions: + self.unsubscribe_typed(sub_id) + + super().shutdown() diff --git a/plugins/auto_updater/__init__.py b/plugins/auto_updater/__init__.py new file mode 100644 index 0000000..9ea3594 --- /dev/null +++ b/plugins/auto_updater/__init__.py @@ -0,0 +1,3 @@ +from .plugin import AutoUpdaterPlugin + +__all__ = ['AutoUpdaterPlugin'] diff --git a/plugins/auto_updater/__pycache__/__init__.cpython-312.pyc b/plugins/auto_updater/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..96d5bc9 Binary files /dev/null and b/plugins/auto_updater/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/auto_updater/__pycache__/plugin.cpython-312.pyc b/plugins/auto_updater/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..8aeada4 Binary files /dev/null and b/plugins/auto_updater/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/auto_updater/plugin.py b/plugins/auto_updater/plugin.py new file mode 100644 index 0000000..4602b4d --- /dev/null +++ b/plugins/auto_updater/plugin.py @@ -0,0 +1,481 @@ +# Description: Auto-updater plugin for EU-Utility +# Checks for updates and installs them automatically + +""" +EU-Utility Auto-Updater + +Features: +- Check for updates from GitHub +- Download and install updates +- Changelog display +- Automatic rollback on failure +- Scheduled update checks +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QProgressBar, QTextEdit, QMessageBox, + QCheckBox, QGroupBox, QComboBox +) +from PyQt6.QtCore import QThread, pyqtSignal, QTimer, Qt +from plugins.base_plugin import BasePlugin +import requests +import json +import os +import shutil +import zipfile +import subprocess +import sys +from datetime import datetime + + +class UpdateWorker(QThread): + """Background worker for update operations.""" + progress = pyqtSignal(int) + status = pyqtSignal(str) + finished_signal = pyqtSignal(bool, str) + + def __init__(self, download_url, install_path, backup_path): + super().__init__() + self.download_url = download_url + self.install_path = install_path + self.backup_path = backup_path + self.temp_download = None + + def run(self): + try: + # Step 1: Download + self.status.emit("Downloading update...") + self._download() + self.progress.emit(33) + + # Step 2: Backup + self.status.emit("Creating backup...") + self._create_backup() + self.progress.emit(66) + + # Step 3: Install + self.status.emit("Installing update...") + self._install() + self.progress.emit(100) + + self.finished_signal.emit(True, "Update installed successfully. Please restart EU-Utility.") + + except Exception as e: + self.status.emit(f"Error: {str(e)}") + self._rollback() + self.finished_signal.emit(False, str(e)) + finally: + # Cleanup temp file + if self.temp_download and os.path.exists(self.temp_download): + try: + os.remove(self.temp_download) + except: + pass + + def _download(self): + """Download update package.""" + self.temp_download = os.path.join(os.path.expanduser('~/.eu-utility'), 'update.zip') + os.makedirs(os.path.dirname(self.temp_download), exist_ok=True) + + response = requests.get(self.download_url, stream=True, timeout=120) + response.raise_for_status() + + with open(self.temp_download, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + def _create_backup(self): + """Create backup of current installation.""" + if os.path.exists(self.install_path): + os.makedirs(self.backup_path, exist_ok=True) + backup_name = f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + backup_full_path = os.path.join(self.backup_path, backup_name) + shutil.copytree(self.install_path, backup_full_path, ignore_dangling_symlinks=True) + + def _install(self): + """Install the update.""" + # Extract update + with zipfile.ZipFile(self.temp_download, 'r') as zip_ref: + # Extract to temp location first + temp_extract = os.path.join(os.path.expanduser('~/.eu-utility'), 'update_extract') + if os.path.exists(temp_extract): + shutil.rmtree(temp_extract) + zip_ref.extractall(temp_extract) + + # Find the actual content (might be in a subdirectory) + contents = os.listdir(temp_extract) + if len(contents) == 1 and os.path.isdir(os.path.join(temp_extract, contents[0])): + source = os.path.join(temp_extract, contents[0]) + else: + source = temp_extract + + # Copy files to install path + for item in os.listdir(source): + s = os.path.join(source, item) + d = os.path.join(self.install_path, item) + + if os.path.isdir(s): + if os.path.exists(d): + shutil.rmtree(d) + shutil.copytree(s, d) + else: + shutil.copy2(s, d) + + # Cleanup + shutil.rmtree(temp_extract) + + def _rollback(self): + """Rollback to backup on failure.""" + try: + # Find most recent backup + if os.path.exists(self.backup_path): + backups = sorted(os.listdir(self.backup_path)) + if backups: + latest_backup = os.path.join(self.backup_path, backups[-1]) + + # Restore from backup + if os.path.exists(self.install_path): + shutil.rmtree(self.install_path) + shutil.copytree(latest_backup, self.install_path) + except: + pass + + +class AutoUpdaterPlugin(BasePlugin): + """ + Auto-updater for EU-Utility. + + Checks for updates from GitHub and installs them automatically. + """ + + name = "Auto Updater" + version = "1.0.0" + author = "LemonNexus" + description = "Automatic update checker and installer" + icon = "refresh" + + # GitHub repository info + GITHUB_REPO = "ImpulsiveFPS/EU-Utility" + GITHUB_API_URL = "https://api.github.com/repos/{}/releases/latest" + + def initialize(self): + """Initialize auto-updater.""" + self.current_version = "2.0.0" # Should be read from version file + self.latest_version = None + self.latest_release = None + self.worker = None + + # Settings + self.check_on_startup = self.load_data("check_on_startup", True) + self.auto_install = self.load_data("auto_install", False) + self.check_interval_hours = self.load_data("check_interval", 24) + + # Check for updates if enabled + if self.check_on_startup: + QTimer.singleShot(5000, self._check_for_updates) # Check after 5 seconds + + def get_ui(self): + """Create updater UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(15) + + # Title + title = QLabel("Auto Updater") + title.setStyleSheet("font-size: 20px; font-weight: bold; color: #4a9eff;") + layout.addWidget(title) + + # Current version + version_group = QGroupBox("Version Information") + version_layout = QVBoxLayout(version_group) + + self.current_version_label = QLabel(f"Current Version: {self.current_version}") + version_layout.addWidget(self.current_version_label) + + self.latest_version_label = QLabel("Latest Version: Checking...") + version_layout.addWidget(self.latest_version_label) + + self.status_label = QLabel("Status: Ready") + self.status_label.setStyleSheet("color: #4caf50;") + version_layout.addWidget(self.status_label) + + layout.addWidget(version_group) + + # Check for updates button + check_btn = QPushButton("Check for Updates") + check_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 12px; + font-weight: bold; + border-radius: 4px; + } + QPushButton:hover { + background-color: #5aafff; + } + """) + check_btn.clicked.connect(self._check_for_updates) + layout.addWidget(check_btn) + + # Changelog + changelog_group = QGroupBox("Changelog") + changelog_layout = QVBoxLayout(changelog_group) + + self.changelog_text = QTextEdit() + self.changelog_text.setReadOnly(True) + self.changelog_text.setPlaceholderText("Check for updates to see changelog...") + changelog_layout.addWidget(self.changelog_text) + + layout.addWidget(changelog_group) + + # Progress + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + self.progress_status = QLabel("") + layout.addWidget(self.progress_status) + + # Update button + self.update_btn = QPushButton("Download and Install Update") + self.update_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 12px; + font-weight: bold; + border-radius: 4px; + } + QPushButton:hover { + background-color: #5cbf60; + } + QPushButton:disabled { + background-color: #555; + } + """) + self.update_btn.setEnabled(False) + self.update_btn.clicked.connect(self._start_update) + layout.addWidget(self.update_btn) + + # Settings + settings_group = QGroupBox("Settings") + settings_layout = QVBoxLayout(settings_group) + + self.startup_checkbox = QCheckBox("Check for updates on startup") + self.startup_checkbox.setChecked(self.check_on_startup) + self.startup_checkbox.toggled.connect(self._on_startup_changed) + settings_layout.addWidget(self.startup_checkbox) + + self.auto_checkbox = QCheckBox("Auto-install updates (not recommended)") + self.auto_checkbox.setChecked(self.auto_install) + self.auto_checkbox.toggled.connect(self._on_auto_changed) + settings_layout.addWidget(self.auto_checkbox) + + interval_layout = QHBoxLayout() + interval_layout.addWidget(QLabel("Check interval:")) + self.interval_combo = QComboBox() + self.interval_combo.addItems(["Every hour", "Every 6 hours", "Every 12 hours", "Daily", "Weekly"]) + self.interval_combo.setCurrentIndex(3) # Daily + self.interval_combo.currentIndexChanged.connect(self._on_interval_changed) + interval_layout.addWidget(self.interval_combo) + settings_layout.addLayout(interval_layout) + + layout.addWidget(settings_group) + + # Manual rollback + rollback_btn = QPushButton("Rollback to Previous Version") + rollback_btn.setStyleSheet("color: #ff9800;") + rollback_btn.clicked.connect(self._rollback_dialog) + layout.addWidget(rollback_btn) + + layout.addStretch() + + return widget + + def _check_for_updates(self): + """Check GitHub for updates.""" + self.status_label.setText("Status: Checking...") + self.status_label.setStyleSheet("color: #ff9800;") + + try: + # Query GitHub API + url = self.GITHUB_API_URL.format(self.GITHUB_REPO) + response = requests.get(url, timeout=30) + response.raise_for_status() + + self.latest_release = response.json() + self.latest_version = self.latest_release['tag_name'].lstrip('v') + + self.latest_version_label.setText(f"Latest Version: {self.latest_version}") + + # Parse changelog + changelog = self.latest_release.get('body', 'No changelog available.') + self.changelog_text.setText(changelog) + + # Compare versions + if self._version_compare(self.latest_version, self.current_version) > 0: + self.status_label.setText("Status: Update available!") + self.status_label.setStyleSheet("color: #4caf50; font-weight: bold;") + self.update_btn.setEnabled(True) + + self.notify_info( + "Update Available", + f"Version {self.latest_version} is available. Check the Auto Updater to install." + ) + else: + self.status_label.setText("Status: Up to date") + self.status_label.setStyleSheet("color: #4caf50;") + self.update_btn.setEnabled(False) + + except Exception as e: + self.status_label.setText(f"Status: Check failed") + self.status_label.setStyleSheet("color: #f44336;") + self.log_error(f"Update check failed: {e}") + + def _version_compare(self, v1, v2): + """Compare two version strings. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal.""" + def normalize(v): + return [int(x) for x in v.split('.')] + + n1 = normalize(v1) + n2 = normalize(v2) + + for i in range(max(len(n1), len(n2))): + x1 = n1[i] if i < len(n1) else 0 + x2 = n2[i] if i < len(n2) else 0 + + if x1 > x2: + return 1 + elif x1 < x2: + return -1 + + return 0 + + def _start_update(self): + """Start the update process.""" + if not self.latest_release: + QMessageBox.warning(self.get_ui(), "No Update", "Please check for updates first.") + return + + # Get download URL + assets = self.latest_release.get('assets', []) + if not assets: + QMessageBox.critical(self.get_ui(), "Error", "No update package found.") + return + + download_url = assets[0]['browser_download_url'] + + # Confirm update + reply = QMessageBox.question( + self.get_ui(), + "Confirm Update", + f"This will update EU-Utility to version {self.latest_version}.\n\n" + "The application will need to restart after installation.\n\n" + "Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply != QMessageBox.StandardButton.Yes: + return + + # Start update worker + install_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + backup_path = os.path.expanduser('~/.eu-utility/backups') + + self.worker = UpdateWorker(download_url, install_path, backup_path) + self.worker.progress.connect(self.progress_bar.setValue) + self.worker.status.connect(self.progress_status.setText) + self.worker.finished_signal.connect(self._on_update_finished) + + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + self.update_btn.setEnabled(False) + + self.worker.start() + + def _on_update_finished(self, success, message): + """Handle update completion.""" + self.progress_bar.setVisible(False) + + if success: + QMessageBox.information( + self.get_ui(), + "Update Complete", + f"{message}\n\nClick OK to restart EU-Utility." + ) + self._restart_application() + else: + QMessageBox.critical( + self.get_ui(), + "Update Failed", + f"Update failed: {message}\n\nRollback was attempted." + ) + self.update_btn.setEnabled(True) + + def _restart_application(self): + """Restart the application.""" + python = sys.executable + script = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'core', 'main.py') + subprocess.Popen([python, script]) + sys.exit(0) + + def _rollback_dialog(self): + """Show rollback dialog.""" + backup_path = os.path.expanduser('~/.eu-utility/backups') + + if not os.path.exists(backup_path): + QMessageBox.information(self.get_ui(), "No Backups", "No backups found.") + return + + backups = sorted(os.listdir(backup_path)) + if not backups: + QMessageBox.information(self.get_ui(), "No Backups", "No backups found.") + return + + # Show simple rollback for now + reply = QMessageBox.question( + self.get_ui(), + "Confirm Rollback", + f"This will restore the most recent backup:\n{backups[-1]}\n\n" + "The application will restart. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + backup = os.path.join(backup_path, backups[-1]) + install_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + # Restore + if os.path.exists(install_path): + shutil.rmtree(install_path) + shutil.copytree(backup, install_path) + + QMessageBox.information( + self.get_ui(), + "Rollback Complete", + "Rollback successful. Click OK to restart." + ) + self._restart_application() + + except Exception as e: + QMessageBox.critical(self.get_ui(), "Rollback Failed", str(e)) + + def _on_startup_changed(self, checked): + """Handle startup check toggle.""" + self.check_on_startup = checked + self.save_data("check_on_startup", checked) + + def _on_auto_changed(self, checked): + """Handle auto-install toggle.""" + self.auto_install = checked + self.save_data("auto_install", checked) + + def _on_interval_changed(self, index): + """Handle check interval change.""" + intervals = [1, 6, 12, 24, 168] # hours + self.check_interval_hours = intervals[index] + self.save_data("check_interval", self.check_interval_hours) diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py new file mode 100644 index 0000000..8934498 --- /dev/null +++ b/plugins/base_plugin.py @@ -0,0 +1,1193 @@ +""" +EU-Utility - Plugin Base Class + +Defines the interface that all plugins must implement. +Includes PluginAPI integration for cross-plugin communication. +""" + +from abc import ABC, abstractmethod +from typing import Optional, Dict, Any, TYPE_CHECKING, Callable, List, Type + +if TYPE_CHECKING: + from core.overlay_window import OverlayWindow + from core.plugin_api import PluginAPI, APIEndpoint, APIType + from core.event_bus import BaseEvent, EventCategory + + +class BasePlugin(ABC): + """Base class for all EU-Utility plugins. + + To define hotkeys for your plugin, use either: + + 1. Legacy single hotkey (simple toggle): + hotkey = "ctrl+shift+n" + + 2. New multi-hotkey format (recommended): + hotkeys = [ + { + 'action': 'toggle', # Unique action identifier + 'description': 'Toggle My Plugin', # Display name in settings + 'default': 'ctrl+shift+m', # Default hotkey combination + 'config_key': 'myplugin_toggle' # Settings key (optional) + }, + { + 'action': 'quick_action', + 'description': 'Quick Scan', + 'default': 'ctrl+shift+s', + } + ] + """ + + # Plugin metadata - override in subclass + name: str = "Unnamed Plugin" + version: str = "1.0.0" + author: str = "Unknown" + description: str = "No description provided" + icon: Optional[str] = None + + # Plugin settings + hotkey: Optional[str] = None # Legacy single hotkey (e.g., "ctrl+shift+n") + hotkeys: Optional[List[Dict[str, str]]] = None # New multi-hotkey format + enabled: bool = True + + # Dependencies - override in subclass + # Format: { + # 'pip': ['package1', 'package2>=1.0'], + # 'plugins': ['plugin_id1', 'plugin_id2'], # Other plugins this plugin requires + # 'optional': {'package3': 'description'} + # } + dependencies: Dict[str, Any] = {} + + def __init__(self, overlay_window: 'OverlayWindow', config: Dict[str, Any]): + self.overlay = overlay_window + self.config = config + self._ui = None + self._api_registered = False + self._plugin_id = f"{self.__class__.__module__}.{self.__class__.__name__}" + + # Track event subscriptions for cleanup + self._event_subscriptions: List[str] = [] + + # Get API instance + try: + from core.plugin_api import get_api + self.api = get_api() + except ImportError: + self.api = None + + @abstractmethod + def initialize(self) -> None: + """Called when plugin is loaded. Setup API connections, etc.""" + pass + + @abstractmethod + def get_ui(self) -> Any: + """Return the plugin's UI widget (QWidget).""" + return None + + def on_show(self) -> None: + """Called when overlay becomes visible.""" + pass + + def on_hide(self) -> None: + """Called when overlay is hidden.""" + pass + + def on_hotkey(self) -> None: + """Called when plugin's hotkey is pressed.""" + pass + + def shutdown(self) -> None: + """Called when app is closing. Cleanup resources.""" + # Unregister APIs + if self.api and self._api_registered: + self.api.unregister_api(self._plugin_id) + + # Unsubscribe from all typed events + self.unsubscribe_all_typed() + + # ========== Config Methods ========== + + def get_config(self, key: str, default: Any = None) -> Any: + """Get a config value with default.""" + return self.config.get(key, default) + + def set_config(self, key: str, value: Any) -> None: + """Set a config value.""" + self.config[key] = value + + # ========== API Methods ========== + + def register_api(self, name: str, handler: Callable, api_type: 'APIType' = None, description: str = "") -> bool: + """Register an API endpoint for other plugins to use. + + Example: + self.register_api( + "scan_window", + self.scan_window, + APIType.OCR, + "Scan game window and return text" + ) + """ + if not self.api: + print(f"[{self.name}] API not available") + return False + + try: + from core.plugin_api import APIEndpoint, APIType + + if api_type is None: + api_type = APIType.UTILITY + + endpoint = APIEndpoint( + name=name, + api_type=api_type, + description=description, + handler=handler, + plugin_id=self._plugin_id, + version=self.version + ) + + success = self.api.register_api(endpoint) + if success: + self._api_registered = True + return success + + except Exception as e: + print(f"[{self.name}] Failed to register API: {e}") + return False + + def call_api(self, plugin_id: str, api_name: str, *args, **kwargs) -> Any: + """Call another plugin's API. + + Example: + # Call Game Reader's OCR API + result = self.call_api("plugins.game_reader.plugin", "capture_screen") + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.call_api(plugin_id, api_name, *args, **kwargs) + + def find_apis(self, api_type: 'APIType' = None) -> list: + """Find available APIs from other plugins.""" + if not self.api: + return [] + + return self.api.find_apis(api_type) + + # ========== Shared Services ========== + + def ocr_capture(self, region: tuple = None) -> Dict[str, Any]: + """Capture screen and perform OCR. + + Returns: + {'text': str, 'confidence': float, 'raw_results': list} + """ + if not self.api: + return {"text": "", "confidence": 0, "error": "API not available"} + + return self.api.ocr_capture(region) + + # ========== Screenshot Service Methods ========== + + def capture_screen(self, full_screen: bool = True): + """Capture screenshot. + + Args: + full_screen: If True, capture entire screen + + Returns: + PIL Image object + + Example: + # Capture full screen + screenshot = self.capture_screen() + + # Capture specific region + region = self.capture_region(100, 100, 800, 600) + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.capture_screen(full_screen) + + def capture_region(self, x: int, y: int, width: int, height: int): + """Capture specific screen region. + + Args: + x: Left coordinate + y: Top coordinate + width: Region width + height: Region height + + Returns: + PIL Image object + + Example: + # Capture a 400x200 region starting at (100, 100) + image = self.capture_region(100, 100, 400, 200) + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.capture_region(x, y, width, height) + + def get_last_screenshot(self): + """Get the most recent screenshot. + + Returns: + PIL Image or None if no screenshots taken yet + """ + if not self.api: + return None + + return self.api.get_last_screenshot() + + def read_log(self, lines: int = 50, filter_text: str = None) -> list: + """Read recent game log lines.""" + if not self.api: + return [] + + return self.api.read_log(lines, filter_text) + + def get_shared_data(self, key: str, default=None): + """Get shared data from other plugins.""" + if not self.api: + return default + + return self.api.get_data(key, default) + + def set_shared_data(self, key: str, value: Any): + """Set shared data for other plugins.""" + if self.api: + self.api.set_data(key, value) + + # ========== Legacy Event System ========== + + def publish_event(self, event_type: str, data: Dict[str, Any]): + """Publish an event for other plugins to consume (legacy).""" + if self.api: + self.api.publish_event(event_type, data) + + def subscribe(self, event_type: str, callback: Callable): + """Subscribe to events from other plugins (legacy).""" + if self.api: + self.api.subscribe(event_type, callback) + + # ========== Enhanced Typed Event System ========== + + def publish_typed(self, event: 'BaseEvent') -> None: + """ + Publish a typed event to the Event Bus. + + Args: + event: A typed event instance (SkillGainEvent, LootEvent, etc.) + + Example: + from core.event_bus import LootEvent + + self.publish_typed(LootEvent( + mob_name="Daikiba", + items=[{"name": "Animal Oil", "value": 0.05}], + total_tt_value=0.05 + )) + """ + if self.api: + self.api.publish_typed(event) + + def subscribe_typed( + self, + event_class: Type['BaseEvent'], + callback: Callable, + **filter_kwargs + ) -> str: + """ + Subscribe to a specific event type with optional filtering. + + Args: + event_class: The event class to subscribe to + callback: Function to call when matching events occur + **filter_kwargs: Additional filter criteria + - min_damage: Minimum damage threshold + - max_damage: Maximum damage threshold + - mob_types: List of mob names to filter + - skill_names: List of skill names to filter + - sources: List of event sources to filter + - replay_last: Number of recent events to replay + - predicate: Custom filter function + + Returns: + Subscription ID (store this to unsubscribe later) + + Example: + from core.event_bus import DamageEvent + + # Subscribe to all damage events + self.sub_id = self.subscribe_typed(DamageEvent, self.on_damage) + + # Subscribe to high damage events only + self.sub_id = self.subscribe_typed( + DamageEvent, + self.on_big_hit, + min_damage=100 + ) + + # Subscribe with replay + self.sub_id = self.subscribe_typed( + SkillGainEvent, + self.on_skill_gain, + replay_last=10 + ) + """ + if not self.api: + print(f"[{self.name}] API not available for event subscription") + return "" + + sub_id = self.api.subscribe_typed(event_class, callback, **filter_kwargs) + if sub_id: + self._event_subscriptions.append(sub_id) + return sub_id + + def unsubscribe_typed(self, subscription_id: str) -> bool: + """ + Unsubscribe from a specific typed event subscription. + + Args: + subscription_id: The subscription ID returned by subscribe_typed + + Returns: + True if subscription was found and removed + """ + if not self.api: + return False + + result = self.api.unsubscribe_typed(subscription_id) + if result and subscription_id in self._event_subscriptions: + self._event_subscriptions.remove(subscription_id) + return result + + def unsubscribe_all_typed(self) -> None: + """Unsubscribe from all typed event subscriptions.""" + if not self.api: + return + + for sub_id in self._event_subscriptions[:]: # Copy list to avoid modification during iteration + self.api.unsubscribe_typed(sub_id) + self._event_subscriptions.clear() + + def get_recent_events( + self, + event_type: Type['BaseEvent'] = None, + count: int = 100, + category: 'EventCategory' = None + ) -> List['BaseEvent']: + """ + Get recent events from history. + + Args: + event_type: Filter by event class + count: Maximum number of events to return + category: Filter by event category + + Returns: + List of matching events + + Example: + from core.event_bus import LootEvent + + # Get last 20 loot events + recent_loot = self.get_recent_events(LootEvent, 20) + """ + if not self.api: + return [] + + return self.api.get_recent_events(event_type, count, category) + + def get_event_stats(self) -> Dict[str, Any]: + """ + Get Event Bus statistics. + + Returns: + Dict with event bus statistics + """ + if not self.api: + return {} + + return self.api.get_event_stats() + + # ========== Utility Methods ========== + + def format_ped(self, value: float) -> str: + """Format PED value.""" + if self.api: + return self.api.format_ped(value) + return f"{value:.2f} PED" + + def format_pec(self, value: float) -> str: + """Format PEC value.""" + if self.api: + return self.api.format_pec(value) + return f"{value:.0f} PEC" + + def calculate_dpp(self, damage: float, ammo: int, decay: float) -> float: + """Calculate Damage Per PEC.""" + if self.api: + return self.api.calculate_dpp(damage, ammo, decay) + + # Fallback calculation + if damage <= 0: + return 0.0 + ammo_cost = ammo * 0.01 + total_cost = ammo_cost + decay + if total_cost <= 0: + return 0.0 + return damage / (total_cost / 100) + + def calculate_markup(self, price: float, tt: float) -> float: + """Calculate markup percentage.""" + if self.api: + return self.api.calculate_markup(price, tt) + + if tt <= 0: + return 0.0 + return (price / tt) * 100 + + # ========== Audio Service Methods ========== + + def play_sound(self, filename_or_key: str, blocking: bool = False) -> bool: + """Play a sound by key or filename. + + Args: + filename_or_key: Sound key ('global', 'hof', 'skill_gain', 'alert', 'error') + or path to file + blocking: If True, wait for sound to complete (default: False) + + Returns: + True if sound was queued/played, False on error or if muted + + Examples: + # Play predefined sounds + self.play_sound('hof') + self.play_sound('skill_gain') + self.play_sound('alert') + + # Play custom sound file + self.play_sound('/path/to/custom.wav') + """ + if not self.api: + return False + + return self.api.play_sound(filename_or_key, blocking) + + def set_volume(self, volume: float) -> None: + """Set global audio volume. + + Args: + volume: Volume level from 0.0 (mute) to 1.0 (max) + """ + if self.api: + self.api.set_volume(volume) + + def get_volume(self) -> float: + """Get current audio volume. + + Returns: + Current volume level (0.0 to 1.0) + """ + if not self.api: + return 0.0 + + return self.api.get_volume() + + def mute(self) -> None: + """Mute all audio.""" + if self.api: + self.api.mute_audio() + + def unmute(self) -> None: + """Unmute audio.""" + if self.api: + self.api.unmute_audio() + + def toggle_mute(self) -> bool: + """Toggle audio mute state. + + Returns: + New muted state (True if now muted) + """ + if not self.api: + return False + + return self.api.toggle_mute_audio() + + def is_muted(self) -> bool: + """Check if audio is muted. + + Returns: + True if audio is muted + """ + if not self.api: + return False + + return self.api.is_audio_muted() + + def is_audio_available(self) -> bool: + """Check if audio service is available. + + Returns: + True if audio backend is initialized and working + """ + if not self.api: + return False + + return self.api.is_audio_available() + + # ========== Background Task Methods ========== + + def run_in_background(self, func: Callable, *args, + priority: str = 'normal', + on_complete: Callable = None, + on_error: Callable = None, + **kwargs) -> str: + """Run a function in a background thread. + + Use this instead of creating your own QThreads. + + Args: + func: Function to execute in background + *args: Positional arguments for the function + priority: 'high', 'normal', or 'low' (default: 'normal') + on_complete: Called with result when task completes successfully + on_error: Called with exception when task fails + **kwargs: Keyword arguments for the function + + Returns: + Task ID for tracking/cancellation + + Example: + def heavy_calculation(data): + return process(data) + + def on_done(result): + self.update_ui(result) + + def on_fail(error): + self.show_error(str(error)) + + task_id = self.run_in_background( + heavy_calculation, + large_dataset, + priority='high', + on_complete=on_done, + on_error=on_fail + ) + + # Or with decorator style: + @self.run_in_background + def fetch_remote_data(): + return requests.get(url).json() + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.run_in_background( + func, *args, + priority=priority, + on_complete=on_complete, + on_error=on_error, + **kwargs + ) + + def schedule_task(self, delay_ms: int, func: Callable, *args, + priority: str = 'normal', + on_complete: Callable = None, + on_error: Callable = None, + periodic: bool = False, + interval_ms: int = None, + **kwargs) -> str: + """Schedule a task for delayed or periodic execution. + + Args: + delay_ms: Milliseconds to wait before first execution + func: Function to execute + *args: Positional arguments + priority: 'high', 'normal', or 'low' + on_complete: Called with result after each execution + on_error: Called with exception if execution fails + periodic: If True, repeat execution at interval_ms + interval_ms: Milliseconds between periodic executions + **kwargs: Keyword arguments + + Returns: + Task ID for tracking/cancellation + + Example: + # One-time delayed execution + task_id = self.schedule_task( + 5000, # 5 seconds + lambda: print("Hello after delay!") + ) + + # Periodic data refresh (every 30 seconds) + self.schedule_task( + 0, # Start immediately + self.refresh_data, + periodic=True, + interval_ms=30000, + on_complete=lambda data: self.update_display(data) + ) + """ + if not self.api: + raise RuntimeError("API not available") + + return self.api.schedule_task( + delay_ms, func, *args, + priority=priority, + on_complete=on_complete, + on_error=on_error, + periodic=periodic, + interval_ms=interval_ms, + **kwargs + ) + + def cancel_task(self, task_id: str) -> bool: + """Cancel a pending or running task. + + Args: + task_id: Task ID returned by run_in_background or schedule_task + + Returns: + True if task was cancelled, False if not found or already done + """ + if not self.api: + return False + + return self.api.cancel_task(task_id) + + def connect_task_signals(self, + on_completed: Callable = None, + on_failed: Callable = None, + on_started: Callable = None, + on_cancelled: Callable = None) -> bool: + """Connect to task status signals for UI updates. + + Connects Qt signals so UI updates from background threads are thread-safe. + + Args: + on_completed: Called with (task_id, result) when tasks complete + on_failed: Called with (task_id, error_message) when tasks fail + on_started: Called with (task_id) when tasks start + on_cancelled: Called with (task_id) when tasks are cancelled + + Returns: + True if signals were connected + + Example: + class MyPlugin(BasePlugin): + def initialize(self): + # Connect task signals for UI updates + self.connect_task_signals( + on_completed=self._on_task_done, + on_failed=self._on_task_error + ) + + def _on_task_done(self, task_id, result): + self.status_label.setText(f"Task {task_id}: Done!") + + def _on_task_error(self, task_id, error): + self.status_label.setText(f"Task {task_id} failed: {error}") + """ + if not self.api: + return False + + connected = False + + if on_completed: + connected = self.api.connect_task_signal('completed', on_completed) or connected + if on_failed: + connected = self.api.connect_task_signal('failed', on_failed) or connected + if on_started: + connected = self.api.connect_task_signal('started', on_started) or connected + if on_cancelled: + connected = self.api.connect_task_signal('cancelled', on_cancelled) or connected + + return connected + + # ========== Nexus API Methods ========== + + def nexus_search(self, query: str, entity_type: str = "items", limit: int = 20) -> List[Dict[str, Any]]: + """Search for entities via Entropia Nexus API. + + Args: + query: Search query string + entity_type: Type of entity to search. Valid types: + - items, weapons, armors + - mobs, pets + - blueprints, materials + - locations, teleporters, shops, planets, areas + - skills + - enhancers, medicaltools, finders, excavators, refiners + - vehicles, decorations, furniture + - storagecontainers, strongboxes, vendors + limit: Maximum number of results (default: 20, max: 100) + + Returns: + List of search result dictionaries + + Example: + # Search for weapons + results = self.nexus_search("ArMatrix", entity_type="weapons") + + # Search for mobs + mobs = self.nexus_search("Atrox", entity_type="mobs") + + # Search for locations + locations = self.nexus_search("Fort", entity_type="locations") + + # Process results + for item in results: + print(f"{item['name']} ({item['type']})") + """ + if not self.api: + return [] + + return self.api.nexus_search(query, entity_type, limit) + + def nexus_get_item_details(self, item_id: str) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific item. + + Args: + item_id: The item's unique identifier (e.g., "armatrix_lp-35") + + Returns: + Dictionary with item details, or None if not found + + Example: + details = self.nexus_get_item_details("armatrix_lp-35") + if details: + print(f"Name: {details['name']}") + print(f"TT Value: {details['tt_value']} PED") + print(f"Damage: {details.get('damage', 'N/A')}") + print(f"Range: {details.get('range', 'N/A')}m") + """ + if not self.api: + return None + + return self.api.nexus_get_item_details(item_id) + + def nexus_get_market_data(self, item_id: str) -> Optional[Dict[str, Any]]: + """Get market data for a specific item. + + Args: + item_id: The item's unique identifier + + Returns: + Dictionary with market data, or None if not found + + Example: + market = self.nexus_get_market_data("armatrix_lp-35") + if market: + print(f"Current markup: {market['current_markup']:.1f}%") + print(f"7-day avg: {market['avg_markup_7d']:.1f}%") + print(f"24h Volume: {market['volume_24h']}") + + # Check orders + for buy in market.get('buy_orders', [])[:5]: + print(f"Buy: {buy['price']} PED x {buy['quantity']}") + """ + if not self.api: + return None + + return self.api.nexus_get_market_data(item_id) + + def nexus_is_available(self) -> bool: + """Check if Nexus API is available. + + Returns: + True if Nexus API service is ready + """ + if not self.api: + return False + + return self.api.nexus_is_available() + + # ========== HTTP Client Methods ========== + + def http_get(self, url: str, cache_ttl: int = 300, headers: Dict[str, str] = None, **kwargs) -> Dict[str, Any]: + """Make an HTTP GET request with caching. + + Args: + url: The URL to fetch + cache_ttl: Cache TTL in seconds (default: 300 = 5 minutes) + headers: Additional headers + **kwargs: Additional arguments + + Returns: + Dict with 'status_code', 'headers', 'content', 'text', 'json', 'from_cache' + + Example: + response = self.http_get( + "https://api.example.com/data", + cache_ttl=600, + headers={'Accept': 'application/json'} + ) + if response['status_code'] == 200: + data = response['json'] + """ + if not self.api: + raise RuntimeError("API not available") + + # Get HTTP client from services + http_client = self.api.services.get('http') + if not http_client: + raise RuntimeError("HTTP client not available") + + return http_client.get(url, cache_ttl=cache_ttl, headers=headers, **kwargs) + + # ========== DataStore Methods ========== + + def save_data(self, key: str, data: Any) -> bool: + """Save data to persistent storage. + + Data is automatically scoped to this plugin and survives app restarts. + + Args: + key: Key to store data under + data: Data to store (must be JSON serializable) + + Returns: + True if saved successfully + + Example: + # Save plugin settings + self.save_data("settings", {"theme": "dark", "volume": 0.8}) + + # Save user progress + self.save_data("total_loot", {"ped": 150.50, "items": 42}) + """ + if not self.api: + return False + + data_store = self.api.services.get('data_store') + if not data_store: + print(f"[{self.name}] DataStore not available") + return False + + return data_store.save(self._plugin_id, key, data) + + def load_data(self, key: str, default: Any = None) -> Any: + """Load data from persistent storage. + + Args: + key: Key to load data from + default: Default value if key doesn't exist + + Returns: + Stored data or default value + + Example: + # Load settings with defaults + settings = self.load_data("settings", {"theme": "light", "volume": 1.0}) + + # Load progress + progress = self.load_data("total_loot", {"ped": 0, "items": 0}) + print(f"Total loot: {progress['ped']} PED") + """ + if not self.api: + return default + + data_store = self.api.services.get('data_store') + if not data_store: + return default + + return data_store.load(self._plugin_id, key, default) + + def delete_data(self, key: str) -> bool: + """Delete data from persistent storage. + + Args: + key: Key to delete + + Returns: + True if deleted (or didn't exist), False on error + """ + if not self.api: + return False + + data_store = self.api.services.get('data_store') + if not data_store: + return False + + return data_store.delete(self._plugin_id, key) + + def get_all_data_keys(self) -> List[str]: + """Get all data keys stored by this plugin. + + Returns: + List of key names + """ + if not self.api: + return [] + + data_store = self.api.services.get('data_store') + if not data_store: + return [] + + return data_store.get_all_keys(self._plugin_id) + + # ========== Window Manager Methods ========== + + def get_eu_window(self) -> Optional[Dict[str, Any]]: + """Get information about the Entropia Universe game window. + + Returns: + Dict with window info or None if not found: + - handle: Window handle (int) + - title: Window title (str) + - rect: (left, top, right, bottom) tuple + - width: Window width (int) + - height: Window height (int) + - visible: Whether window is visible (bool) + + Example: + window = self.get_eu_window() + if window: + print(f"EU window: {window['width']}x{window['height']}") + print(f"Position: {window['rect']}") + """ + if not self.api: + return None + + return self.api.get_eu_window() + + def is_eu_focused(self) -> bool: + """Check if Entropia Universe window is currently focused. + + Returns: + True if EU is the active window + + Example: + if self.is_eu_focused(): + # Safe to capture screenshot + screenshot = self.capture_screen() + """ + if not self.api: + return False + + return self.api.is_eu_focused() + + def is_eu_visible(self) -> bool: + """Check if Entropia Universe window is visible. + + Returns: + True if EU window is visible (not minimized) + """ + if not self.api: + return False + + return self.api.is_eu_visible() + + def bring_eu_to_front(self) -> bool: + """Bring Entropia Universe window to front and focus it. + + Returns: + True if successful + """ + if not self.api: + return False + + return self.api.bring_eu_to_front() + + # ========== Clipboard Methods ========== + + def copy_to_clipboard(self, text: str) -> bool: + """Copy text to system clipboard. + + Args: + text: Text to copy + + Returns: + True if successful + + Example: + # Copy coordinates + self.copy_to_clipboard("12345, 67890") + + # Copy calculation result + result = self.calculate_dpp(50, 100, 2.5) + self.copy_to_clipboard(f"DPP: {result:.2f}") + """ + if not self.api: + return False + + return self.api.copy_to_clipboard(text) + + def paste_from_clipboard(self) -> str: + """Paste text from system clipboard. + + Returns: + Clipboard content or empty string + + Example: + # Get pasted coordinates + coords = self.paste_from_clipboard() + if coords: + x, y = map(int, coords.split(",")) + """ + if not self.api: + return "" + + return self.api.paste_from_clipboard() + + def get_clipboard_history(self, limit: int = 10) -> List[Dict[str, str]]: + """Get recent clipboard history. + + Args: + limit: Maximum number of entries to return + + Returns: + List of clipboard entries with 'text', 'timestamp', 'source' + """ + if not self.api: + return [] + + return self.api.get_clipboard_history(limit) + + # ========== Notification Methods ========== + + def notify(self, title: str, message: str, notification_type: str = 'info', sound: bool = False, duration_ms: int = 5000) -> str: + """Show a toast notification. + + Args: + title: Notification title + message: Notification message + notification_type: 'info', 'warning', 'error', or 'success' + sound: Play notification sound + duration_ms: How long to show notification (default: 5000ms) + + Returns: + Notification ID + + Example: + # Info notification + self.notify("Session Started", "Tracking loot...") + + # Success with sound + self.notify("Global!", "You received 150 PED", notification_type='success', sound=True) + + # Warning + self.notify("Low Ammo", "Only 100 shots remaining", notification_type='warning') + + # Error + self.notify("Connection Failed", "Check your internet", notification_type='error', sound=True) + """ + if not self.api: + return "" + + return self.api.notify(title, message, notification_type, sound, duration_ms) + + def notify_info(self, title: str, message: str, sound: bool = False) -> str: + """Show info notification (convenience method).""" + return self.notify(title, message, 'info', sound) + + def notify_success(self, title: str, message: str, sound: bool = False) -> str: + """Show success notification (convenience method).""" + return self.notify(title, message, 'success', sound) + + def notify_warning(self, title: str, message: str, sound: bool = False) -> str: + """Show warning notification (convenience method).""" + return self.notify(title, message, 'warning', sound) + + def notify_error(self, title: str, message: str, sound: bool = True) -> str: + """Show error notification (convenience method).""" + return self.notify(title, message, 'error', sound) + + def close_notification(self, notification_id: str) -> bool: + """Close a specific notification. + + Args: + notification_id: ID returned by notify() + + Returns: + True if closed + """ + if not self.api: + return False + + return self.api.close_notification(notification_id) + + def close_all_notifications(self) -> None: + """Close all visible notifications.""" + if not self.api: + return + + self.api.close_all_notifications() + + # ========== Settings Methods ========== + + def get_setting(self, key: str, default: Any = None) -> Any: + """Get a global EU-Utility setting. + + These are user preferences that apply across all plugins. + + Args: + key: Setting key + default: Default value if not set + + Returns: + Setting value + + Available settings: + - theme: 'dark', 'light', or 'auto' + - overlay_opacity: float 0.0-1.0 + - icon_size: 'small', 'medium', 'large' + - minimize_to_tray: bool + - show_tooltips: bool + - global_hotkeys: Dict of hotkey mappings + """ + if not self.api: + return default + + settings = self.api.services.get('settings') + if not settings: + return default + + return settings.get(key, default) + + def set_setting(self, key: str, value: Any) -> bool: + """Set a global EU-Utility setting. + + Args: + key: Setting key + value: Value to set + + Returns: + True if saved + """ + if not self.api: + return False + + settings = self.api.services.get('settings') + if not settings: + return False + + return settings.set(key, value) + + # ========== Logging Methods ========== + + def log_debug(self, message: str) -> None: + """Log debug message (development only).""" + print(f"[DEBUG][{self.name}] {message}") + + def log_info(self, message: str) -> None: + """Log info message.""" + print(f"[INFO][{self.name}] {message}") + + def log_warning(self, message: str) -> None: + """Log warning message.""" + print(f"[WARNING][{self.name}] {message}") + + def log_error(self, message: str) -> None: + """Log error message.""" + print(f"[ERROR][{self.name}] {message}") diff --git a/plugins/calculator/__init__.py b/plugins/calculator/__init__.py new file mode 100644 index 0000000..b5ba8bf --- /dev/null +++ b/plugins/calculator/__init__.py @@ -0,0 +1,7 @@ +""" +Calculator Plugin for EU-Utility +""" + +from .plugin import CalculatorPlugin + +__all__ = ["CalculatorPlugin"] diff --git a/plugins/calculator/__pycache__/__init__.cpython-312.pyc b/plugins/calculator/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..73b0248 Binary files /dev/null and b/plugins/calculator/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/calculator/__pycache__/plugin.cpython-312.pyc b/plugins/calculator/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..02fdc8f Binary files /dev/null and b/plugins/calculator/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/calculator/plugin.py b/plugins/calculator/plugin.py new file mode 100644 index 0000000..2514914 --- /dev/null +++ b/plugins/calculator/plugin.py @@ -0,0 +1,386 @@ +""" +EU-Utility - Calculator Plugin + +Standard calculator with Windows-style layout. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLineEdit, QPushButton, QLabel, QGridLayout +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class CalculatorPlugin(BasePlugin): + """Standard calculator with Windows-style layout.""" + + name = "Calculator" + version = "1.1.0" + author = "ImpulsiveFPS" + description = "Standard calculator" + hotkey = "ctrl+shift+c" + + def initialize(self): + """Setup calculator.""" + self.current_value = "0" + self.stored_value = None + self.pending_op = None + self.memory = 0 + self.start_new = True + + def get_ui(self): + """Create calculator UI with Windows layout.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(2) + + # Title + title = QLabel("🧮 Calculator") + title.setStyleSheet("color: #4a9eff; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Display + self.display = QLineEdit("0") + self.display.setAlignment(Qt.AlignmentFlag.AlignRight) + self.display.setStyleSheet(""" + QLineEdit { + background-color: #1a1a1a; + color: white; + font-size: 32px; + font-family: 'Segoe UI', Arial; + padding: 15px; + border: none; + border-radius: 4px; + } + """) + self.display.setReadOnly(True) + layout.addWidget(self.display) + + # Button grid - Windows Calculator Layout + grid = QGridLayout() + grid.setSpacing(2) + + # Row 1: Memory buttons + mem_buttons = ['MC', 'MR', 'M+', 'M-', 'MS', 'M~'] + for i, btn_text in enumerate(mem_buttons): + btn = self._create_button(btn_text, "#3a3a3a") + btn.clicked.connect(lambda checked, t=btn_text: self._on_memory(t)) + grid.addWidget(btn, 0, i) + + # Row 2: %, CE, C, ⌫ + row2 = ['%', 'CE', 'C', '⌫'] + for i, btn_text in enumerate(row2): + btn = self._create_button(btn_text, "#3a3a3a") + btn.clicked.connect(lambda checked, t=btn_text: self._on_special(t)) + grid.addWidget(btn, 1, i) + + # Row 3: ¹/ₓ, x², ²√x, ÷ + row3 = [('¹/ₓ', '1/x'), ('x²', 'sq'), ('²√x', 'sqrt'), '÷'] + for i, item in enumerate(row3): + if isinstance(item, tuple): + text, op = item + else: + text = op = item + btn = self._create_button(text, "#3a3a3a") + btn.clicked.connect(lambda checked, o=op: self._on_operator(o)) + grid.addWidget(btn, 2, i) + + # Row 4: 7, 8, 9, × + row4 = ['7', '8', '9', '×'] + for i, btn_text in enumerate(row4): + btn = self._create_button(btn_text, "#2a2a2a", is_number=btn_text not in ['×']) + if btn_text == '×': + btn.clicked.connect(lambda checked: self._on_operator('*')) + else: + btn.clicked.connect(lambda checked, t=btn_text: self._on_number(t)) + grid.addWidget(btn, 3, i) + + # Row 5: 4, 5, 6, - + row5 = ['4', '5', '6', '-'] + for i, btn_text in enumerate(row5): + btn = self._create_button(btn_text, "#2a2a2a", is_number=btn_text not in ['-']) + if btn_text == '-': + btn.clicked.connect(lambda checked: self._on_operator('-')) + else: + btn.clicked.connect(lambda checked, t=btn_text: self._on_number(t)) + grid.addWidget(btn, 4, i) + + # Row 6: 1, 2, 3, + + row6 = ['1', '2', '3', '+'] + for i, btn_text in enumerate(row6): + btn = self._create_button(btn_text, "#2a2a2a", is_number=btn_text not in ['+']) + if btn_text == '+': + btn.clicked.connect(lambda checked: self._on_operator('+')) + else: + btn.clicked.connect(lambda checked, t=btn_text: self._on_number(t)) + grid.addWidget(btn, 5, i) + + # Row 7: +/-, 0, ., = + row7 = [('±', '+/-'), '0', '.', '='] + for i, item in enumerate(row7): + if isinstance(item, tuple): + text, val = item + else: + text = val = item + + if val == '=': + btn = self._create_button(text, "#0078d4", text_color="white") # Blue equals + else: + btn = self._create_button(text, "#2a2a2a", is_number=True) + + if val == '+/-': + btn.clicked.connect(self._on_negate) + elif val == '.': + btn.clicked.connect(self._on_decimal) + elif val == '=': + btn.clicked.connect(self._on_equals) + else: + btn.clicked.connect(lambda checked, t=val: self._on_number(t)) + + grid.addWidget(btn, 6, i) + + # Set column stretch + for i in range(4): + grid.setColumnStretch(i, 1) + + # Set row stretch + for i in range(7): + grid.setRowStretch(i, 1) + + layout.addLayout(grid) + layout.addStretch() + + return widget + + def _create_button(self, text, bg_color, is_number=False, text_color="#ffffff"): + """Create a calculator button.""" + btn = QPushButton(text) + btn.setMinimumSize(60, 45) + + if is_number: + font_size = "18px" + font_weight = "normal" + else: + font_size = "14px" + font_weight = "normal" + + btn.setStyleSheet(f""" + QPushButton {{ + background-color: {bg_color}; + color: {text_color}; + font-size: {font_size}; + font-weight: {font_weight}; + border: none; + border-radius: 4px; + }} + QPushButton:hover {{ + background-color: {self._lighten(bg_color)}; + }} + QPushButton:pressed {{ + background-color: {self._darken(bg_color)}; + }} + """) + + return btn + + def _lighten(self, color): + """Lighten a hex color slightly.""" + # Simple approximation - increase each component + if color == "#2a2a2a": + return "#3a3a3a" + elif color == "#3a3a3a": + return "#4a4a4a" + elif color == "#0078d4": + return "#1084e0" + return color + + def _darken(self, color): + """Darken a hex color slightly.""" + if color == "#2a2a2a": + return "#1a1a1a" + elif color == "#3a3a3a": + return "#2a2a2a" + elif color == "#0078d4": + return "#006cbd" + return color + + def _on_number(self, num): + """Handle number button press.""" + if self.start_new: + self.current_value = num + self.start_new = False + else: + if self.current_value == "0": + self.current_value = num + else: + self.current_value += num + self._update_display() + + def _on_decimal(self): + """Handle decimal point.""" + if self.start_new: + self.current_value = "0." + self.start_new = False + elif "." not in self.current_value: + self.current_value += "." + self._update_display() + + def _on_operator(self, op): + """Handle operator button.""" + try: + current = float(self.current_value) + + if op == "1/x": + result = 1 / current + self.current_value = self._format_result(result) + self.start_new = True + elif op == "sq": + result = current ** 2 + self.current_value = self._format_result(result) + self.start_new = True + elif op == "sqrt": + import math + result = math.sqrt(current) + self.current_value = self._format_result(result) + self.start_new = True + else: + # Binary operators + if self.pending_op and not self.start_new: + self._calculate() + + self.stored_value = float(self.current_value) + self.pending_op = op + self.start_new = True + + self._update_display() + except Exception: + self.current_value = "Error" + self._update_display() + self.start_new = True + + def _on_special(self, op): + """Handle special buttons (%, CE, C, backspace).""" + if op == 'C': + # Clear all + self.current_value = "0" + self.stored_value = None + self.pending_op = None + self.start_new = True + elif op == 'CE': + # Clear entry + self.current_value = "0" + self.start_new = True + elif op == '⌫': + # Backspace + if len(self.current_value) > 1: + self.current_value = self.current_value[:-1] + else: + self.current_value = "0" + elif op == '%': + # Percent + try: + result = float(self.current_value) / 100 + self.current_value = self._format_result(result) + self.start_new = True + except: + self.current_value = "Error" + self.start_new = True + + self._update_display() + + def _on_memory(self, op): + """Handle memory operations.""" + try: + current = float(self.current_value) + + if op == 'MC': + self.memory = 0 + elif op == 'MR': + self.current_value = self._format_result(self.memory) + self.start_new = True + elif op == 'M+': + self.memory += current + elif op == 'M-': + self.memory -= current + elif op == 'MS': + self.memory = current + elif op == 'M~': + # Memory clear (same as MC) + self.memory = 0 + + self._update_display() + except: + pass + + def _on_negate(self): + """Toggle sign.""" + try: + current = float(self.current_value) + result = -current + self.current_value = self._format_result(result) + self._update_display() + except: + pass + + def _on_equals(self): + """Calculate result.""" + self._calculate() + self.pending_op = None + self.stored_value = None + self.start_new = True + + def _calculate(self): + """Perform pending calculation.""" + if self.pending_op and self.stored_value is not None: + try: + current = float(self.current_value) + + if self.pending_op == '+': + result = self.stored_value + current + elif self.pending_op == '-': + result = self.stored_value - current + elif self.pending_op == '*': + result = self.stored_value * current + elif self.pending_op == '÷': + if current != 0: + result = self.stored_value / current + else: + result = "Error" + else: + return + + self.current_value = self._format_result(result) + self._update_display() + except: + self.current_value = "Error" + self._update_display() + + def _format_result(self, result): + """Format calculation result.""" + if isinstance(result, str): + return result + + # Check if it's essentially an integer + if result == int(result): + return str(int(result)) + + # Format with reasonable precision + formatted = f"{result:.10f}" + # Remove trailing zeros + formatted = formatted.rstrip('0').rstrip('.') + + # Limit length + if len(formatted) > 12: + formatted = f"{result:.6e}" + + return formatted + + def _update_display(self): + """Update the display.""" + self.display.setText(self.current_value) + + def on_hotkey(self): + """Focus calculator when hotkey pressed.""" + pass diff --git a/plugins/chat_logger/__init__.py b/plugins/chat_logger/__init__.py new file mode 100644 index 0000000..5e50812 --- /dev/null +++ b/plugins/chat_logger/__init__.py @@ -0,0 +1,7 @@ +""" +Chat Logger Plugin +""" + +from .plugin import ChatLoggerPlugin + +__all__ = ["ChatLoggerPlugin"] diff --git a/plugins/chat_logger/__pycache__/__init__.cpython-312.pyc b/plugins/chat_logger/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2aef34b Binary files /dev/null and b/plugins/chat_logger/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/chat_logger/__pycache__/plugin.cpython-312.pyc b/plugins/chat_logger/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..d2a53a6 Binary files /dev/null and b/plugins/chat_logger/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/chat_logger/plugin.py b/plugins/chat_logger/plugin.py new file mode 100644 index 0000000..27278f6 --- /dev/null +++ b/plugins/chat_logger/plugin.py @@ -0,0 +1,285 @@ +""" +EU-Utility - Chat Logger Plugin + +Log and search chat messages with filters. +""" + +import re +import json +from datetime import datetime, timedelta +from pathlib import Path +from collections import deque + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTextEdit, QLineEdit, QComboBox, + QCheckBox, QFrame +) +from PyQt6.QtCore import Qt, QTimer + +from plugins.base_plugin import BasePlugin +from core.icon_manager import get_icon_manager + + +class ChatLoggerPlugin(BasePlugin): + """Log and search chat messages.""" + + name = "Chat Logger" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Log, search, and filter chat messages" + hotkey = "ctrl+shift+t" # T for chaT + + # Chat channels + CHANNELS = { + 'main': 'Main', + 'society': 'Society', + 'team': 'Team', + 'local': 'Local', + 'global': 'Global', + 'trade': 'Trade', + 'private': 'Private', + } + + def initialize(self): + """Setup chat logger.""" + self.data_file = Path("data/chat_log.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + # Keep last 10000 messages in memory + self.messages = deque(maxlen=10000) + self.filters = { + 'show_main': True, + 'show_society': True, + 'show_team': True, + 'show_local': True, + 'show_global': True, + 'show_trade': True, + 'show_private': True, + 'search_text': '', + 'show_globals_only': False, + 'show_loot': False, + } + + self._load_recent() + + def _load_recent(self): + """Load recent messages.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.messages.extend(data.get('messages', [])[-1000:]) + except: + pass + + def _save_messages(self): + """Save messages to file.""" + # Keep last 24 hours + cutoff = (datetime.now() - timedelta(hours=24)).isoformat() + recent = [m for m in self.messages if m['time'] > cutoff] + + with open(self.data_file, 'w') as f: + json.dump({'messages': recent}, f) + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + # Get icon manager + icon_mgr = get_icon_manager() + + # Title with icon + title_layout = QHBoxLayout() + + title_icon = QLabel() + icon_pixmap = icon_mgr.get_pixmap('message-square', size=20) + title_icon.setPixmap(icon_pixmap) + title_icon.setFixedSize(20, 20) + title_layout.addWidget(title_icon) + + title = QLabel("Chat Logger") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + title_layout.addWidget(title) + title_layout.addStretch() + + layout.addLayout(title_layout) + + # Search bar + search_layout = QHBoxLayout() + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search messages...") + self.search_input.setStyleSheet(""" + QLineEdit { + background-color: rgba(255, 255, 255, 15); + color: white; + border: 1px solid rgba(255, 255, 255, 30); + border-radius: 6px; + padding: 8px; + } + """) + self.search_input.textChanged.connect(self._update_filter) + search_layout.addWidget(self.search_input) + + search_btn = QPushButton("🔍") + search_btn.setFixedSize(32, 32) + search_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255, 255, 255, 15); + border: none; + border-radius: 6px; + font-size: 14px; + } + QPushButton:hover { + background-color: rgba(255, 255, 255, 30); + } + """) + search_layout.addWidget(search_btn) + + layout.addLayout(search_layout) + + # Filters + filters_frame = QFrame() + filters_frame.setStyleSheet(""" + QFrame { + background-color: rgba(0, 0, 0, 50); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 20); + } + """) + filters_layout = QHBoxLayout(filters_frame) + filters_layout.setContentsMargins(10, 6, 10, 6) + + # Channel filters + self.filter_checks = {} + for channel_id, channel_name in self.CHANNELS.items(): + cb = QCheckBox(channel_name) + cb.setChecked(True) + cb.setStyleSheet("color: rgba(255, 255, 255, 180); font-size: 10px;") + cb.stateChanged.connect(self._update_filter) + self.filter_checks[channel_id] = cb + filters_layout.addWidget(cb) + + filters_layout.addStretch() + layout.addWidget(filters_frame) + + # Chat display + self.chat_display = QTextEdit() + self.chat_display.setReadOnly(True) + self.chat_display.setStyleSheet(""" + QTextEdit { + background-color: rgba(20, 25, 35, 150); + color: white; + border: 1px solid rgba(255, 255, 255, 20); + border-radius: 8px; + padding: 10px; + font-family: Consolas, monospace; + font-size: 11px; + } + """) + layout.addWidget(self.chat_display) + + # Stats + self.stats_label = QLabel("Messages: 0") + self.stats_label.setStyleSheet("color: rgba(255, 255, 255, 100); font-size: 10px;") + layout.addWidget(self.stats_label) + + # Refresh display + self._refresh_display() + + return widget + + def _update_filter(self): + """Update filter settings.""" + self.filters['search_text'] = self.search_input.text().lower() + + for channel_id, cb in self.filter_checks.items(): + self.filters[f'show_{channel_id}'] = cb.isChecked() + + self._refresh_display() + + def _refresh_display(self): + """Refresh chat display.""" + html = [] + + for msg in reversed(self.messages): + # Apply filters + channel = msg.get('channel', 'main') + if not self.filters.get(f'show_{channel}', True): + continue + + text = msg.get('text', '') + if self.filters['search_text']: + if self.filters['search_text'] not in text.lower(): + continue + + # Format message + time_str = msg['time'][11:16] if msg['time'] else '--:--' + author = msg.get('author', 'Unknown') + + # Color by channel + colors = { + 'main': '#ffffff', + 'society': '#9c27b0', + 'team': '#4caf50', + 'local': '#ffc107', + 'global': '#f44336', + 'trade': '#ff9800', + 'private': '#00bcd4', + } + color = colors.get(channel, '#ffffff') + + html.append(f''' +
+ [{time_str}] + {author}: + {text} +
+ ''') + + self.chat_display.setHtml(''.join(html[:100])) # Show last 100 + self.stats_label.setText(f"Messages: {len(self.messages)}") + + def parse_chat_message(self, message, channel='main', author='Unknown'): + """Parse and log chat message.""" + entry = { + 'time': datetime.now().isoformat(), + 'channel': channel, + 'author': author, + 'text': message, + } + + self.messages.append(entry) + self._refresh_display() + + # Auto-save periodically + if len(self.messages) % 100 == 0: + self._save_messages() + + def search(self, query): + """Search chat history.""" + results = [] + query_lower = query.lower() + + for msg in self.messages: + if query_lower in msg.get('text', '').lower(): + results.append(msg) + + return results + + def get_globals(self): + """Get global messages.""" + return [m for m in self.messages if m.get('channel') == 'global'] + + def get_loot_messages(self): + """Get loot-related messages.""" + loot_keywords = ['received', 'loot', 'item', 'ped'] + return [ + m for m in self.messages + if any(kw in m.get('text', '').lower() for kw in loot_keywords) + ] diff --git a/plugins/codex_tracker/__init__.py b/plugins/codex_tracker/__init__.py new file mode 100644 index 0000000..dcba8fd --- /dev/null +++ b/plugins/codex_tracker/__init__.py @@ -0,0 +1,7 @@ +""" +Codex Tracker Plugin +""" + +from .plugin import CodexTrackerPlugin + +__all__ = ["CodexTrackerPlugin"] diff --git a/plugins/codex_tracker/__pycache__/__init__.cpython-312.pyc b/plugins/codex_tracker/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..95b6e6f Binary files /dev/null and b/plugins/codex_tracker/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/codex_tracker/__pycache__/plugin.cpython-312.pyc b/plugins/codex_tracker/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..2f063e0 Binary files /dev/null and b/plugins/codex_tracker/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/codex_tracker/plugin.py b/plugins/codex_tracker/plugin.py new file mode 100644 index 0000000..e36aed2 --- /dev/null +++ b/plugins/codex_tracker/plugin.py @@ -0,0 +1,218 @@ +""" +EU-Utility - Codex Tracker Plugin + +Track creature challenge progress from Codex. +""" + +import json +from pathlib import Path +from datetime import datetime + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QProgressBar, QTableWidget, QTableWidgetItem, + QComboBox, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class CodexTrackerPlugin(BasePlugin): + """Track creature codex progress.""" + + name = "Codex Tracker" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track creature challenges and codex progress" + hotkey = "ctrl+shift+x" + + # Arkadia creatures from screenshot + CREATURES = [ + {"name": "Bokol", "rank": 22, "progress": 49.5}, + {"name": "Nusul", "rank": 15, "progress": 24.8}, + {"name": "Wombana", "rank": 10, "progress": 45.2}, + {"name": "Arkadian Hornet", "rank": 1, "progress": 15.0}, + {"name": "Feran", "rank": 4, "progress": 86.0}, + {"name": "Hadraada", "rank": 6, "progress": 0.4}, + {"name": "Halix", "rank": 14, "progress": 0.2}, + {"name": "Huon", "rank": 1, "progress": 45.8}, + {"name": "Kadra", "rank": 2, "progress": 0.7}, + {"name": "Magurg", "rank": 1, "progress": 8.7}, + {"name": "Monura", "rank": 1, "progress": 5.2}, + ] + + def initialize(self): + """Setup codex tracker.""" + self.data_file = Path("data/codex.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.creatures = self.CREATURES.copy() + self.scanned_data = {} + + self._load_data() + + def _load_data(self): + """Load codex data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.creatures = data.get('creatures', self.CREATURES) + except: + pass + + def _save_data(self): + """Save codex data.""" + with open(self.data_file, 'w') as f: + json.dump({'creatures': self.creatures}, f, indent=2) + + def get_ui(self): + """Create codex tracker UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("📖 Codex Tracker") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Summary + summary = QHBoxLayout() + + total_creatures = len(self.creatures) + completed = sum(1 for c in self.creatures if c.get('progress', 0) >= 100) + + self.total_label = QLabel(f"Creatures: {total_creatures}") + self.total_label.setStyleSheet("color: #4a9eff; font-size: 13px;") + summary.addWidget(self.total_label) + + self.completed_label = QLabel(f"Completed: {completed}") + self.completed_label.setStyleSheet("color: #4caf50; font-size: 13px;") + summary.addWidget(self.completed_label) + + summary.addStretch() + layout.addLayout(summary) + + # Scan button + scan_btn = QPushButton("Scan Codex Window") + scan_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: white; + padding: 12px; + border: none; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #ffa060; + } + """) + scan_btn.clicked.connect(self._scan_codex) + layout.addWidget(scan_btn) + + # Creatures list + self.creatures_table = QTableWidget() + self.creatures_table.setColumnCount(4) + self.creatures_table.setHorizontalHeaderLabels(["Creature", "Rank", "Progress", "Next Rank"]) + self.creatures_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 8px; + border: none; + font-weight: bold; + font-size: 11px; + } + """) + self.creatures_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.creatures_table) + + self._refresh_table() + + layout.addStretch() + return widget + + def _refresh_table(self): + """Refresh creatures table.""" + self.creatures_table.setRowCount(len(self.creatures)) + + for i, creature in enumerate(sorted(self.creatures, key=lambda x: -x.get('progress', 0))): + # Name + name_item = QTableWidgetItem(creature.get('name', 'Unknown')) + name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.creatures_table.setItem(i, 0, name_item) + + # Rank + rank = creature.get('rank', 0) + rank_item = QTableWidgetItem(str(rank)) + rank_item.setFlags(rank_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + rank_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.creatures_table.setItem(i, 1, rank_item) + + # Progress bar + progress = creature.get('progress', 0) + progress_widget = QWidget() + progress_layout = QHBoxLayout(progress_widget) + progress_layout.setContentsMargins(5, 2, 5, 2) + + bar = QProgressBar() + bar.setValue(int(progress)) + bar.setTextVisible(True) + bar.setFormat(f"{progress:.1f}%") + bar.setStyleSheet(""" + QProgressBar { + background-color: rgba(60, 70, 90, 150); + border: none; + border-radius: 3px; + text-align: center; + color: white; + font-size: 10px; + } + QProgressBar::chunk { + background-color: #ff8c42; + border-radius: 3px; + } + """) + progress_layout.addWidget(bar) + + self.creatures_table.setCellWidget(i, 2, progress_widget) + + # Next rank (estimated kills needed) + progress_item = QTableWidgetItem(f"~{int((100-progress) * 120)} kills") + progress_item.setFlags(progress_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + progress_item.setForeground(Qt.GlobalColor.gray) + self.creatures_table.setItem(i, 3, progress_item) + + def _scan_codex(self): + """Scan codex window with OCR.""" + # TODO: Implement OCR scanning + # For now, simulate update + for creature in self.creatures: + creature['progress'] = min(100, creature.get('progress', 0) + 0.5) + + self._save_data() + self._refresh_table() + + def get_closest_to_rankup(self, count=3): + """Get creatures closest to ranking up.""" + sorted_creatures = sorted( + self.creatures, + key=lambda x: -(x.get('progress', 0)) + ) + return [c for c in sorted_creatures[:count] if c.get('progress', 0) < 100] + + def get_recommended_hunt(self): + """Get recommended creature to hunt.""" + close = self.get_closest_to_rankup(1) + return close[0] if close else None diff --git a/plugins/crafting_calc/__init__.py b/plugins/crafting_calc/__init__.py new file mode 100644 index 0000000..371956a --- /dev/null +++ b/plugins/crafting_calc/__init__.py @@ -0,0 +1,7 @@ +""" +Crafting Calculator Plugin +""" + +from .plugin import CraftingCalculatorPlugin + +__all__ = ["CraftingCalculatorPlugin"] diff --git a/plugins/crafting_calc/__pycache__/__init__.cpython-312.pyc b/plugins/crafting_calc/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..eb61ff8 Binary files /dev/null and b/plugins/crafting_calc/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/crafting_calc/__pycache__/plugin.cpython-312.pyc b/plugins/crafting_calc/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..ac2302f Binary files /dev/null and b/plugins/crafting_calc/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/crafting_calc/plugin.py b/plugins/crafting_calc/plugin.py new file mode 100644 index 0000000..bd79a00 --- /dev/null +++ b/plugins/crafting_calc/plugin.py @@ -0,0 +1,219 @@ +""" +EU-Utility - Crafting Calculator Plugin + +Calculate crafting success rates and material costs. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QComboBox, QLineEdit, QTableWidget, + QTableWidgetItem, QFrame, QGroupBox +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class CraftingCalculatorPlugin(BasePlugin): + """Calculate crafting costs and success rates.""" + + name = "Crafting Calc" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Crafting success rates and material costs" + hotkey = "ctrl+shift+b" # B for Blueprint + + # Sample blueprints data + BLUEPRINTS = { + 'Weapon': [ + 'ArMatrix LP-35 (L)', + 'ArMatrix BP-25 (L)', + 'Omegaton M83 Predator', + ], + 'Armor': [ + 'Vigiator Harness (M)', + 'Vigiator Thighs (M)', + ], + 'Tool': [ + 'Ziplex Z1 Seeker', + 'Ziplex Z3 Seeker', + ], + 'Material': [ + 'Metal Residue', + 'Energy Matter Residue', + ], + } + + def initialize(self): + """Setup crafting calculator.""" + self.saved_recipes = [] + + def get_ui(self): + """Create crafting calculator UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("🔨 Crafting Calculator") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Blueprint selector + bp_group = QGroupBox("Blueprint") + bp_group.setStyleSheet(self._group_style()) + bp_layout = QVBoxLayout(bp_group) + + # Category + cat_layout = QHBoxLayout() + cat_layout.addWidget(QLabel("Category:")) + self.cat_combo = QComboBox() + self.cat_combo.addItems(list(self.BLUEPRINTS.keys())) + self.cat_combo.currentTextChanged.connect(self._update_blueprints) + cat_layout.addWidget(self.cat_combo) + bp_layout.addLayout(cat_layout) + + # Blueprint + bp_layout2 = QHBoxLayout() + bp_layout2.addWidget(QLabel("Blueprint:")) + self.bp_combo = QComboBox() + self._update_blueprints(self.cat_combo.currentText()) + bp_layout2.addWidget(self.bp_combo) + bp_layout.addLayout(bp_layout2) + + # QR + qr_layout = QHBoxLayout() + qr_layout.addWidget(QLabel("QR:")) + self.qr_input = QLineEdit() + self.qr_input.setPlaceholderText("1.0") + self.qr_input.setText("1.0") + qr_layout.addWidget(self.qr_input) + bp_layout.addLayout(qr_layout) + + layout.addWidget(bp_group) + + # Materials + mat_group = QGroupBox("Materials") + mat_group.setStyleSheet(self._group_style()) + mat_layout = QVBoxLayout(mat_group) + + self.mat_table = QTableWidget() + self.mat_table.setColumnCount(4) + self.mat_table.setHorizontalHeaderLabels(["Material", "Needed", "Have", "Buy"]) + self.mat_table.setRowCount(3) + + sample_mats = [ + ("Lysterium Ingot", 50, 0), + ("Oil", 30, 10), + ("Meldar Paper", 10, 5), + ] + + for i, (mat, needed, have) in enumerate(sample_mats): + self.mat_table.setItem(i, 0, QTableWidgetItem(mat)) + self.mat_table.setItem(i, 1, QTableWidgetItem(str(needed))) + self.mat_table.setItem(i, 2, QTableWidgetItem(str(have))) + buy = needed - have if needed > have else 0 + self.mat_table.setItem(i, 3, QTableWidgetItem(str(buy))) + + self.mat_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: none; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 6px; + font-size: 10px; + } + """) + mat_layout.addWidget(self.mat_table) + + layout.addWidget(mat_group) + + # Calculator + calc_group = QGroupBox("Calculator") + calc_group.setStyleSheet(self._group_style()) + calc_layout = QVBoxLayout(calc_group) + + # Click calculator + click_layout = QHBoxLayout() + click_layout.addWidget(QLabel("Clicks:")) + self.clicks_input = QLineEdit() + self.clicks_input.setPlaceholderText("10") + self.clicks_input.setText("10") + click_layout.addWidget(self.clicks_input) + calc_layout.addLayout(click_layout) + + calc_btn = QPushButton("Calculate") + calc_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + calc_btn.clicked.connect(self._calculate) + calc_layout.addWidget(calc_btn) + + # Results + self.result_label = QLabel("Success Rate: ~45%") + self.result_label.setStyleSheet("color: #4caf50; font-weight: bold;") + calc_layout.addWidget(self.result_label) + + self.cost_label = QLabel("Estimated Cost: 15.50 PED") + self.cost_label.setStyleSheet("color: #ffc107;") + calc_layout.addWidget(self.cost_label) + + layout.addWidget(calc_group) + layout.addStretch() + + return widget + + def _group_style(self): + return """ + QGroupBox { + color: rgba(255,255,255,200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + margin-top: 10px; + font-weight: bold; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + """ + + def _update_blueprints(self, category): + """Update blueprint list.""" + self.bp_combo.clear() + self.bp_combo.addItems(self.BLUEPRINTS.get(category, [])) + + def _calculate(self): + """Calculate crafting results.""" + try: + qr = float(self.qr_input.text() or 1.0) + clicks = int(self.clicks_input.text() or 10) + + # Simple formula (real one is more complex) + base_rate = 0.45 + qr_bonus = (qr - 1.0) * 0.05 + success_rate = min(0.95, base_rate + qr_bonus) + + expected_success = int(clicks * success_rate) + + self.result_label.setText( + f"Success Rate: ~{success_rate*100:.1f}% | " + f"Expected: {expected_success}/{clicks}" + ) + + except Exception as e: + self.result_label.setText(f"Error: {e}") diff --git a/plugins/dashboard/__init__.py b/plugins/dashboard/__init__.py new file mode 100644 index 0000000..448dc14 --- /dev/null +++ b/plugins/dashboard/__init__.py @@ -0,0 +1,7 @@ +""" +Dashboard Plugin +""" + +from .plugin import DashboardPlugin + +__all__ = ["DashboardPlugin"] diff --git a/plugins/dashboard/__pycache__/__init__.cpython-312.pyc b/plugins/dashboard/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..eb94fc5 Binary files /dev/null and b/plugins/dashboard/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/dashboard/__pycache__/plugin.cpython-312.pyc b/plugins/dashboard/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..2fa6db3 Binary files /dev/null and b/plugins/dashboard/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/dashboard/plugin.py b/plugins/dashboard/plugin.py new file mode 100644 index 0000000..ece6750 --- /dev/null +++ b/plugins/dashboard/plugin.py @@ -0,0 +1,326 @@ +""" +EU-Utility - Dashboard Plugin with Customizable Widgets + +Customizable start page with avatar statistics. +""" + +import json +from pathlib import Path +from datetime import datetime, timedelta + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QGridLayout, QFrame, QScrollArea, + QSizePolicy, QCheckBox, QDialog, QListWidget, + QListWidgetItem, QDialogButtonBox +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QColor, QFont + +from core.eu_styles import EU_COLORS +from plugins.base_plugin import BasePlugin + + +class DashboardPlugin(BasePlugin): + """Customizable dashboard with avatar statistics.""" + + name = "Dashboard" + version = "2.0.0" + author = "ImpulsiveFPS" + description = "Customizable start page with avatar stats" + hotkey = "ctrl+shift+home" + + # Available widgets + AVAILABLE_WIDGETS = { + 'ped_balance': {'name': 'PED Balance', 'icon': 'dollar-sign', 'default': True}, + 'skill_count': {'name': 'Skills Tracked', 'icon': 'trending-up', 'default': True}, + 'inventory_items': {'name': 'Inventory Items', 'icon': 'archive', 'default': True}, + 'current_dpp': {'name': 'Current DPP', 'icon': 'crosshair', 'default': True}, + 'total_gains_today': {'name': "Today's Skill Gains", 'icon': 'zap', 'default': True}, + 'professions_count': {'name': 'Professions', 'icon': 'award', 'default': False}, + 'missions_active': {'name': 'Active Missions', 'icon': 'map', 'default': False}, + 'codex_progress': {'name': 'Codex Progress', 'icon': 'book', 'default': False}, + 'globals_hofs': {'name': 'Globals/HOFs', 'icon': 'package', 'default': False}, + 'play_time': {'name': 'Session Time', 'icon': 'clock', 'default': False}, + } + + def initialize(self): + """Setup dashboard.""" + self.config_file = Path("data/dashboard_config.json") + self.config_file.parent.mkdir(parents=True, exist_ok=True) + + self.enabled_widgets = [] + self.widget_data = {} + + self._load_config() + self._load_data() + + # Auto-refresh timer + self.refresh_timer = QTimer() + self.refresh_timer.timeout.connect(self._refresh_data) + self.refresh_timer.start(5000) # Refresh every 5 seconds + + def _load_config(self): + """Load widget configuration.""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + config = json.load(f) + self.enabled_widgets = config.get('enabled', []) + except: + pass + + # Default: enable default widgets + if not self.enabled_widgets: + self.enabled_widgets = [ + k for k, v in self.AVAILABLE_WIDGETS.items() if v['default'] + ] + + def _save_config(self): + """Save widget configuration.""" + with open(self.config_file, 'w') as f: + json.dump({'enabled': self.enabled_widgets}, f) + + def _load_data(self): + """Load data from other plugins.""" + # Try to get data from other plugin files + data_dir = Path("data") + + # PED from inventory + inv_file = data_dir / "inventory.json" + if inv_file.exists(): + try: + with open(inv_file, 'r') as f: + data = json.load(f) + items = data.get('items', []) + total_tt = sum(item.get('tt', 0) for item in items) + self.widget_data['ped_balance'] = total_tt + except: + self.widget_data['ped_balance'] = 0 + + # Skills + skills_file = data_dir / "skill_tracker.json" + if skills_file.exists(): + try: + with open(skills_file, 'r') as f: + data = json.load(f) + self.widget_data['skill_count'] = len(data.get('skills', {})) + self.widget_data['total_gains_today'] = len([ + g for g in data.get('gains', []) + if datetime.fromisoformat(g['time']).date() == datetime.now().date() + ]) + except: + self.widget_data['skill_count'] = 0 + self.widget_data['total_gains_today'] = 0 + + # Inventory count + if inv_file.exists(): + try: + with open(inv_file, 'r') as f: + data = json.load(f) + self.widget_data['inventory_items'] = len(data.get('items', [])) + except: + self.widget_data['inventory_items'] = 0 + + # Professions + prof_file = data_dir / "professions.json" + if prof_file.exists(): + try: + with open(prof_file, 'r') as f: + data = json.load(f) + self.widget_data['professions_count'] = len(data.get('professions', {})) + except: + self.widget_data['professions_count'] = 0 + + def _refresh_data(self): + """Refresh widget data.""" + self._load_data() + if hasattr(self, 'widgets_container'): + self._update_widgets() + + def get_ui(self): + """Create dashboard UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Header with customize button + header = QHBoxLayout() + + title = QLabel("Dashboard") + title.setStyleSheet("font-size: 20px; font-weight: bold; color: white;") + header.addWidget(title) + + header.addStretch() + + customize_btn = QPushButton("Customize") + customize_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {EU_COLORS['bg_secondary']}; + color: {EU_COLORS['text_secondary']}; + border: 1px solid {EU_COLORS['border_default']}; + border-radius: 4px; + padding: 8px 16px; + }} + QPushButton:hover {{ + background-color: {EU_COLORS['bg_hover']}; + border-color: {EU_COLORS['accent_orange']}; + }} + """) + customize_btn.clicked.connect(self._show_customize_dialog) + header.addWidget(customize_btn) + + layout.addLayout(header) + + # Scroll area for widgets + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent; border: none;") + + self.widgets_container = QWidget() + self.widgets_layout = QGridLayout(self.widgets_container) + self.widgets_layout.setSpacing(15) + self.widgets_layout.setContentsMargins(0, 0, 0, 0) + + self._update_widgets() + + scroll.setWidget(self.widgets_container) + layout.addWidget(scroll) + + return widget + + def _update_widgets(self): + """Update widget display.""" + # Clear existing + while self.widgets_layout.count(): + item = self.widgets_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + # Add enabled widgets + col = 0 + row = 0 + for widget_id in self.enabled_widgets: + if widget_id in self.AVAILABLE_WIDGETS: + widget_info = self.AVAILABLE_WIDGETS[widget_id] + card = self._create_widget_card( + widget_id, + widget_info['name'], + widget_info['icon'] + ) + self.widgets_layout.addWidget(card, row, col) + + col += 1 + if col >= 2: # 2 columns + col = 0 + row += 1 + + def _create_widget_card(self, widget_id, name, icon_name): + """Create a stat widget card.""" + card = QFrame() + card.setStyleSheet(f""" + QFrame {{ + background-color: {EU_COLORS['bg_secondary']}; + border: 1px solid {EU_COLORS['border_default']}; + border-radius: 8px; + }} + """) + + layout = QVBoxLayout(card) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(8) + + # Title + title = QLabel(name) + title.setStyleSheet(f"color: {EU_COLORS['text_muted']}; font-size: 11px;") + layout.addWidget(title) + + # Value + value = self.widget_data.get(widget_id, 0) + + if widget_id == 'ped_balance': + value_text = f"{value:.2f} PED" + elif widget_id == 'play_time': + value_text = "2h 34m" # Placeholder + elif widget_id == 'current_dpp': + value_text = "3.45" + else: + value_text = str(value) + + value_label = QLabel(value_text) + value_label.setStyleSheet(f""" + color: {EU_COLORS['accent_orange']}; + font-size: 24px; + font-weight: bold; + """) + layout.addWidget(value_label) + + layout.addStretch() + return card + + def _show_customize_dialog(self): + """Show widget customization dialog.""" + dialog = QDialog() + dialog.setWindowTitle("Customize Dashboard") + dialog.setStyleSheet(f""" + QDialog {{ + background-color: {EU_COLORS['bg_secondary']}; + color: white; + }} + QLabel {{ + color: white; + }} + """) + + layout = QVBoxLayout(dialog) + + # Instructions + info = QLabel("Check widgets to display on dashboard:") + info.setStyleSheet(f"color: {EU_COLORS['text_secondary']};") + layout.addWidget(info) + + # Widget list + list_widget = QListWidget() + list_widget.setStyleSheet(f""" + QListWidget {{ + background-color: {EU_COLORS['bg_secondary']}; + color: white; + border: 1px solid {EU_COLORS['border_default']}; + }} + QListWidget::item {{ + padding: 10px; + }} + """) + + for widget_id, widget_info in self.AVAILABLE_WIDGETS.items(): + item = QListWidgetItem(widget_info['name']) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState( + Qt.CheckState.Checked if widget_id in self.enabled_widgets + else Qt.CheckState.Unchecked + ) + item.setData(Qt.ItemDataRole.UserRole, widget_id) + list_widget.addItem(item) + + layout.addWidget(list_widget) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addWidget(buttons) + + if dialog.exec() == QDialog.DialogCode.Accepted: + # Save selection + self.enabled_widgets = [] + for i in range(list_widget.count()): + item = list_widget.item(i) + if item.checkState() == Qt.CheckState.Checked: + self.enabled_widgets.append(item.data(Qt.ItemDataRole.UserRole)) + + self._save_config() + self._update_widgets() diff --git a/plugins/discord_presence/__init__.py b/plugins/discord_presence/__init__.py new file mode 100644 index 0000000..977afb9 --- /dev/null +++ b/plugins/discord_presence/__init__.py @@ -0,0 +1,3 @@ +from .plugin import DiscordPresencePlugin + +__all__ = ['DiscordPresencePlugin'] diff --git a/plugins/discord_presence/__pycache__/__init__.cpython-312.pyc b/plugins/discord_presence/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..413c945 Binary files /dev/null and b/plugins/discord_presence/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/discord_presence/__pycache__/plugin.cpython-312.pyc b/plugins/discord_presence/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..5a86051 Binary files /dev/null and b/plugins/discord_presence/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/discord_presence/plugin.py b/plugins/discord_presence/plugin.py new file mode 100644 index 0000000..b834e66 --- /dev/null +++ b/plugins/discord_presence/plugin.py @@ -0,0 +1,217 @@ +# Description: Discord Rich Presence plugin for EU-Utility +# Author: LemonNexus +# Version: 1.0.0 + +""" +Discord Rich Presence plugin for EU-Utility. +Shows current Entropia Universe activity in Discord. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QLineEdit, QCheckBox, QComboBox +) +from PyQt6.QtCore import QTimer +from plugins.base_plugin import BasePlugin +import time +import threading + + +class DiscordPresencePlugin(BasePlugin): + """Discord Rich Presence integration for EU-Utility.""" + + name = "Discord Presence" + version = "1.0.0" + author = "LemonNexus" + description = "Show EU activity in Discord status" + icon = "message-square" + + def initialize(self): + """Initialize Discord RPC.""" + self.enabled = self.load_data("enabled", False) + self.client_id = self.load_data("client_id", "") + self.show_details = self.load_data("show_details", True) + self.rpc = None + self.connected = False + + # Try to import pypresence + try: + from pypresence import Presence + self.Presence = Presence + self.available = True + except ImportError: + self.log_warning("pypresence not installed. Run: pip install pypresence") + self.available = False + return + + # Auto-connect if enabled + if self.enabled and self.client_id: + self._connect() + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(15) + + # Title + title = QLabel("Discord Rich Presence") + title.setStyleSheet("font-size: 18px; font-weight: bold; color: #5865F2;") + layout.addWidget(title) + + # Status + self.status_label = QLabel("Status: " + ("Connected" if self.connected else "Disconnected")) + self.status_label.setStyleSheet( + f"color: {'#43b581' if self.connected else '#f04747'}; font-weight: bold;" + ) + layout.addWidget(self.status_label) + + # Enable checkbox + self.enable_checkbox = QCheckBox("Enable Discord Presence") + self.enable_checkbox.setChecked(self.enabled) + self.enable_checkbox.toggled.connect(self._on_enable_toggled) + layout.addWidget(self.enable_checkbox) + + # Client ID input + id_layout = QHBoxLayout() + id_layout.addWidget(QLabel("Client ID:")) + self.id_input = QLineEdit(self.client_id) + self.id_input.setPlaceholderText("Your Discord Application Client ID") + id_layout.addWidget(self.id_input) + layout.addLayout(id_layout) + + # Show details + self.details_checkbox = QCheckBox("Show detailed activity") + self.details_checkbox.setChecked(self.show_details) + self.details_checkbox.toggled.connect(self._on_details_toggled) + layout.addWidget(self.details_checkbox) + + # Activity type + layout.addWidget(QLabel("Activity Type:")) + self.activity_combo = QComboBox() + self.activity_combo.addItems(["Playing", "Hunting", "Mining", "Crafting", "Trading"]) + self.activity_combo.currentTextChanged.connect(self._update_presence) + layout.addWidget(self.activity_combo) + + # Buttons + btn_layout = QHBoxLayout() + + connect_btn = QPushButton("Connect") + connect_btn.clicked.connect(self._connect) + btn_layout.addWidget(connect_btn) + + disconnect_btn = QPushButton("Disconnect") + disconnect_btn.clicked.connect(self._disconnect) + btn_layout.addWidget(disconnect_btn) + + update_btn = QPushButton("Update Presence") + update_btn.clicked.connect(self._update_presence) + btn_layout.addWidget(update_btn) + + layout.addLayout(btn_layout) + + # Info + info = QLabel( + "To use this feature:\n" + "1. Create a Discord app at discord.com/developers\n" + "2. Copy the Client ID\n" + "3. Paste it above and click Connect" + ) + info.setStyleSheet("color: rgba(255,255,255,150); font-size: 11px;") + layout.addWidget(info) + + layout.addStretch() + return widget + + def _on_enable_toggled(self, checked): + """Handle enable toggle.""" + self.enabled = checked + self.save_data("enabled", checked) + if checked: + self._connect() + else: + self._disconnect() + + def _on_details_toggled(self, checked): + """Handle details toggle.""" + self.show_details = checked + self.save_data("show_details", checked) + self._update_presence() + + def _connect(self): + """Connect to Discord.""" + if not self.available: + self.log_error("pypresence not installed") + return + + client_id = self.id_input.text().strip() + if not client_id: + self.log_warning("No Client ID provided") + return + + self.client_id = client_id + self.save_data("client_id", client_id) + + try: + self.rpc = self.Presence(client_id) + self.rpc.connect() + self.connected = True + self.status_label.setText("Status: Connected") + self.status_label.setStyleSheet("color: #43b581; font-weight: bold;") + self._update_presence() + self.log_info("Discord RPC connected") + except Exception as e: + self.log_error(f"Failed to connect: {e}") + self.connected = False + + def _disconnect(self): + """Disconnect from Discord.""" + if self.rpc: + try: + self.rpc.close() + except: + pass + self.rpc = None + self.connected = False + self.status_label.setText("Status: Disconnected") + self.status_label.setStyleSheet("color: #f04747; font-weight: bold;") + self.log_info("Discord RPC disconnected") + + def _update_presence(self): + """Update Discord presence.""" + if not self.connected or not self.rpc: + return + + activity = self.activity_combo.currentText() + + try: + if self.show_details: + self.rpc.update( + state=f"In Entropia Universe", + details=f"Currently {activity}", + large_image="eu_logo", + large_text="Entropia Universe", + small_image="playing", + small_text=activity, + start=time.time() + ) + else: + self.rpc.update( + state="Playing Entropia Universe", + large_image="eu_logo", + large_text="Entropia Universe" + ) + self.log_info("Presence updated") + except Exception as e: + self.log_error(f"Failed to update presence: {e}") + + def on_show(self): + """Update UI when shown.""" + self.status_label.setText("Status: " + ("Connected" if self.connected else "Disconnected")) + self.status_label.setStyleSheet( + f"color: {'#43b581' if self.connected else '#f04747'}; font-weight: bold;" + ) + + def shutdown(self): + """Cleanup on shutdown.""" + self._disconnect() diff --git a/plugins/dpp_calculator/__init__.py b/plugins/dpp_calculator/__init__.py new file mode 100644 index 0000000..1130b97 --- /dev/null +++ b/plugins/dpp_calculator/__init__.py @@ -0,0 +1,7 @@ +""" +DPP Calculator Plugin +""" + +from .plugin import DPPCalculatorPlugin + +__all__ = ["DPPCalculatorPlugin"] diff --git a/plugins/dpp_calculator/__pycache__/__init__.cpython-312.pyc b/plugins/dpp_calculator/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b11881b Binary files /dev/null and b/plugins/dpp_calculator/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/dpp_calculator/__pycache__/plugin.cpython-312.pyc b/plugins/dpp_calculator/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..16765eb Binary files /dev/null and b/plugins/dpp_calculator/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/dpp_calculator/plugin.py b/plugins/dpp_calculator/plugin.py new file mode 100644 index 0000000..2d1bfb2 --- /dev/null +++ b/plugins/dpp_calculator/plugin.py @@ -0,0 +1,230 @@ +""" +EU-Utility - DPP Calculator Plugin + +Calculate Damage Per PEC for weapons and setups. +""" + +from decimal import Decimal, ROUND_HALF_UP +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QComboBox, QTableWidget, + QTableWidgetItem, QFrame, QGroupBox +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin +from core.icon_manager import get_icon_manager + + +class DPPCalculatorPlugin(BasePlugin): + """Calculate weapon efficiency and DPP.""" + + name = "DPP Calculator" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Calculate Damage Per PEC and weapon efficiency" + hotkey = "ctrl+shift+d" + + def initialize(self): + """Setup DPP calculator.""" + self.saved_setups = [] + + def get_ui(self): + """Create DPP calculator UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Get icon manager + icon_mgr = get_icon_manager() + + # Title with icon + title_layout = QHBoxLayout() + + title_icon = QLabel() + icon_pixmap = icon_mgr.get_pixmap('target', size=20) + title_icon.setPixmap(icon_pixmap) + title_icon.setFixedSize(20, 20) + title_layout.addWidget(title_icon) + + title = QLabel("DPP Calculator") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + title_layout.addWidget(title) + title_layout.addStretch() + + layout.addLayout(title_layout) + + # Calculator section + calc_group = QGroupBox("Weapon Setup") + calc_group.setStyleSheet(""" + QGroupBox { + color: rgba(255,255,255,200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + margin-top: 10px; + font-weight: bold; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + """) + calc_layout = QVBoxLayout(calc_group) + + # Weapon damage + dmg_layout = QHBoxLayout() + dmg_layout.addWidget(QLabel("Damage:")) + self.dmg_input = QLineEdit() + self.dmg_input.setPlaceholderText("e.g., 45.2") + dmg_layout.addWidget(self.dmg_input) + calc_layout.addLayout(dmg_layout) + + # Ammo cost + ammo_layout = QHBoxLayout() + ammo_layout.addWidget(QLabel("Ammo per shot:")) + self.ammo_input = QLineEdit() + self.ammo_input.setPlaceholderText("e.g., 100") + ammo_layout.addWidget(self.ammo_input) + calc_layout.addLayout(ammo_layout) + + # Weapon decay + decay_layout = QHBoxLayout() + decay_layout.addWidget(QLabel("Decay (PEC):")) + self.decay_input = QLineEdit() + self.decay_input.setPlaceholderText("e.g., 2.5") + decay_layout.addWidget(self.decay_input) + calc_layout.addLayout(decay_layout) + + # Amp/scope + amp_layout = QHBoxLayout() + amp_layout.addWidget(QLabel("Amp decay:")) + self.amp_input = QLineEdit() + self.amp_input.setPlaceholderText("0") + self.amp_input.setText("0") + amp_layout.addWidget(self.amp_input) + calc_layout.addLayout(amp_layout) + + # Calculate button + calc_btn = QPushButton("Calculate DPP") + calc_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #ffa060; + } + """) + calc_btn.clicked.connect(self._calculate) + calc_layout.addWidget(calc_btn) + + # Results + self.result_label = QLabel("DPP: -") + self.result_label.setStyleSheet(""" + color: #4caf50; + font-size: 20px; + font-weight: bold; + """) + calc_layout.addWidget(self.result_label) + + self.cost_label = QLabel("Cost per shot: -") + self.cost_label.setStyleSheet("color: rgba(255,255,255,150);") + calc_layout.addWidget(self.cost_label) + + layout.addWidget(calc_group) + + # Reference table + ref_group = QGroupBox("DPP Reference") + ref_group.setStyleSheet(calc_group.styleSheet()) + ref_layout = QVBoxLayout(ref_group) + + ref_table = QTableWidget() + ref_table.setColumnCount(3) + ref_table.setHorizontalHeaderLabels(["Rating", "DPP Range", "Efficiency"]) + ref_table.setRowCount(5) + + ratings = [ + ("Excellent", "4.0+", "95-100%"), + ("Very Good", "3.5-4.0", "85-95%"), + ("Good", "3.0-3.5", "75-85%"), + ("Average", "2.5-3.0", "60-75%"), + ("Poor", "< 2.5", "< 60%"), + ] + + for i, (rating, dpp, eff) in enumerate(ratings): + ref_table.setItem(i, 0, QTableWidgetItem(rating)) + ref_table.setItem(i, 1, QTableWidgetItem(dpp)) + ref_table.setItem(i, 2, QTableWidgetItem(eff)) + + ref_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: none; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 6px; + font-size: 10px; + } + """) + ref_layout.addWidget(ref_table) + + layout.addWidget(ref_group) + layout.addStretch() + + return widget + + def _calculate(self): + """Calculate DPP.""" + try: + damage = Decimal(self.dmg_input.text() or 0) + ammo = Decimal(self.ammo_input.text() or 0) + decay = Decimal(self.decay_input.text() or 0) + amp = Decimal(self.amp_input.text() or 0) + + # Convert ammo to PEC (1 ammo = 0.0001 PED = 0.01 PEC) + ammo_cost = ammo * Decimal('0.01') + + # Total cost in PEC + total_cost = ammo_cost + decay + amp + + if total_cost > 0: + dpp = damage / (total_cost / Decimal('100')) # Convert to PED for DPP + + self.result_label.setText(f"DPP: {dpp:.3f}") + self.result_label.setStyleSheet(f""" + color: {'#4caf50' if dpp >= Decimal('3.5') else '#ffc107' if dpp >= Decimal('2.5') else '#f44336'}; + font-size: 20px; + font-weight: bold; + """) + + cost_ped = total_cost / Decimal('100') + self.cost_label.setText(f"Cost per shot: {cost_ped:.4f} PED ({total_cost:.2f} PEC)") + else: + self.result_label.setText("DPP: Enter values") + + except Exception as e: + self.result_label.setText(f"Error: {e}") + + def calculate_from_api(self, weapon_data): + """Calculate DPP from Nexus API weapon data.""" + damage = Decimal(str(weapon_data.get('damage', 0))) + decay = Decimal(str(weapon_data.get('decay', 0))) + ammo = Decimal(str(weapon_data.get('ammo', 0))) + + # Calculate + ammo_cost = ammo * Decimal('0.01') + total_cost = ammo_cost + decay + + if total_cost > 0: + return damage / (total_cost / Decimal('100')) + return Decimal('0') diff --git a/plugins/enhancer_calc/__init__.py b/plugins/enhancer_calc/__init__.py new file mode 100644 index 0000000..8758bb8 --- /dev/null +++ b/plugins/enhancer_calc/__init__.py @@ -0,0 +1,7 @@ +""" +Enhancer Calculator Plugin +""" + +from .plugin import EnhancerCalculatorPlugin + +__all__ = ["EnhancerCalculatorPlugin"] diff --git a/plugins/enhancer_calc/__pycache__/__init__.cpython-312.pyc b/plugins/enhancer_calc/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7d88674 Binary files /dev/null and b/plugins/enhancer_calc/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/enhancer_calc/__pycache__/plugin.cpython-312.pyc b/plugins/enhancer_calc/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..3fe33c0 Binary files /dev/null and b/plugins/enhancer_calc/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/enhancer_calc/plugin.py b/plugins/enhancer_calc/plugin.py new file mode 100644 index 0000000..a5cc975 --- /dev/null +++ b/plugins/enhancer_calc/plugin.py @@ -0,0 +1,160 @@ +""" +EU-Utility - Enhancer Calculator Plugin + +Calculate enhancer break rates and costs. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QComboBox, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class EnhancerCalculatorPlugin(BasePlugin): + """Calculate enhancer usage and costs.""" + + name = "Enhancer Calc" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Calculate enhancer break rates and costs" + hotkey = "ctrl+shift+e" + + # Break rates (approximate) + BREAK_RATES = { + 'Accuracy': 0.0012, # 0.12% + 'Damage': 0.0015, # 0.15% + 'Economy': 0.0010, # 0.10% + 'Range': 0.0013, # 0.13% + } + + def initialize(self): + """Setup enhancer calculator.""" + self.saved_calculations = [] + + def get_ui(self): + """Create enhancer calculator UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel(" Enhancer Calculator") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Calculator + calc_frame = QFrame() + calc_frame.setStyleSheet(""" + QFrame { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + """) + calc_layout = QVBoxLayout(calc_frame) + calc_layout.setSpacing(10) + + # Enhancer type + type_layout = QHBoxLayout() + type_layout.addWidget(QLabel("Type:")) + self.type_combo = QComboBox() + self.type_combo.addItems(list(self.BREAK_RATES.keys())) + self.type_combo.setStyleSheet(""" + QComboBox { + background-color: rgba(20, 25, 35, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + padding: 5px; + } + """) + type_layout.addWidget(self.type_combo) + calc_layout.addLayout(type_layout) + + # TT value + tt_layout = QHBoxLayout() + tt_layout.addWidget(QLabel("TT Value:")) + self.tt_input = QLineEdit() + self.tt_input.setPlaceholderText("e.g., 40.00") + tt_layout.addWidget(self.tt_input) + calc_layout.addLayout(tt_layout) + + # Number of shots + shots_layout = QHBoxLayout() + shots_layout.addWidget(QLabel("Shots/hour:")) + self.shots_input = QLineEdit() + self.shots_input.setPlaceholderText("e.g., 3600") + self.shots_input.setText("3600") + shots_layout.addWidget(self.shots_input) + calc_layout.addLayout(shots_layout) + + # Calculate button + calc_btn = QPushButton("Calculate") + calc_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + calc_btn.clicked.connect(self._calculate) + calc_layout.addWidget(calc_btn) + + # Results + self.break_rate_label = QLabel("Break rate: -") + self.break_rate_label.setStyleSheet("color: rgba(255,255,255,180);") + calc_layout.addWidget(self.break_rate_label) + + self.breaks_label = QLabel("Expected breaks/hour: -") + self.breaks_label.setStyleSheet("color: #f44336;") + calc_layout.addWidget(self.breaks_label) + + self.cost_label = QLabel("Cost/hour: -") + self.cost_label.setStyleSheet("color: #ffc107;") + calc_layout.addWidget(self.cost_label) + + layout.addWidget(calc_frame) + + # Info + info = QLabel(""" + Typical break rates: + • Damage: ~0.15% per shot + • Accuracy: ~0.12% per shot + • Economy: ~0.10% per shot + • Range: ~0.13% per shot + """) + info.setStyleSheet("color: rgba(255,255,255,120); font-size: 10px;") + info.setWordWrap(True) + layout.addWidget(info) + + layout.addStretch() + return widget + + def _calculate(self): + """Calculate enhancer costs.""" + try: + enhancer_type = self.type_combo.currentText() + tt_value = float(self.tt_input.text() or 0) + shots = int(self.shots_input.text() or 3600) + + break_rate = self.BREAK_RATES.get(enhancer_type, 0.001) + + # Expected breaks + expected_breaks = shots * break_rate + + # Cost + hourly_cost = expected_breaks * tt_value + + self.break_rate_label.setText(f"Break rate: {break_rate*100:.3f}% per shot") + self.breaks_label.setText(f"Expected breaks/hour: {expected_breaks:.2f}") + self.cost_label.setText(f"Cost/hour: {hourly_cost:.2f} PED") + + except Exception as e: + self.break_rate_label.setText(f"Error: {e}") diff --git a/plugins/event_bus_example/__init__.py b/plugins/event_bus_example/__init__.py new file mode 100644 index 0000000..887ba92 --- /dev/null +++ b/plugins/event_bus_example/__init__.py @@ -0,0 +1,4 @@ +"""Event Bus Example Plugin.""" +from .plugin import EventBusExamplePlugin + +__all__ = ['EventBusExamplePlugin'] diff --git a/plugins/event_bus_example/__pycache__/__init__.cpython-312.pyc b/plugins/event_bus_example/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c464527 Binary files /dev/null and b/plugins/event_bus_example/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/event_bus_example/__pycache__/plugin.cpython-312.pyc b/plugins/event_bus_example/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..c4010e5 Binary files /dev/null and b/plugins/event_bus_example/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/event_bus_example/plugin.py b/plugins/event_bus_example/plugin.py new file mode 100644 index 0000000..1b3081e --- /dev/null +++ b/plugins/event_bus_example/plugin.py @@ -0,0 +1,211 @@ +""" +Example plugin demonstrating Enhanced Event Bus usage. + +This plugin shows how to use: +- publish_typed() - Publish typed events +- subscribe_typed() - Subscribe with filtering +- get_recent_events() - Retrieve event history +- Event filtering (min_damage, mob_types, etc.) +- Event replay for new subscribers +""" + +from plugins.base_plugin import BasePlugin +from core.event_bus import ( + SkillGainEvent, LootEvent, DamageEvent, GlobalEvent, + EventCategory +) + + +class EventBusExamplePlugin(BasePlugin): + """Example plugin showing Enhanced Event Bus usage.""" + + name = "Event Bus Example" + version = "1.0.0" + author = "EU-Utility" + description = "Demonstrates Enhanced Event Bus features" + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + self.big_hits = [] + self.skill_gains = [] + self.dragon_loot = [] + self._subscriptions = [] + + def initialize(self) -> None: + """Setup event subscriptions.""" + print(f"[{self.name}] Initializing...") + + # 1. Subscribe to ALL damage events + sub_id = self.subscribe_typed( + DamageEvent, + self.on_any_damage, + replay_last=5 # Replay last 5 damage events on subscribe + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to all damage events") + + # 2. Subscribe to HIGH damage events only (filtering) + sub_id = self.subscribe_typed( + DamageEvent, + self.on_big_damage, + min_damage=100, # Only events with damage >= 100 + replay_last=3 + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to high damage events (≥100)") + + # 3. Subscribe to skill gains for specific skills + sub_id = self.subscribe_typed( + SkillGainEvent, + self.on_combat_skill_gain, + skill_names=["Rifle", "Handgun", "Sword", "Knife"], + replay_last=10 + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to combat skill gains") + + # 4. Subscribe to loot from specific mobs + sub_id = self.subscribe_typed( + LootEvent, + self.on_dragon_loot, + mob_types=["Dragon", "Drake", "Dragon Old"], + replay_last=5 + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to Dragon/Drake loot") + + # 5. Subscribe to ALL globals + sub_id = self.subscribe_typed( + GlobalEvent, + self.on_global_announcement + ) + self._subscriptions.append(sub_id) + print(f"[{self.name}] Subscribed to global announcements") + + # 6. Demonstrate publishing events + self._publish_example_events() + + # 7. Show event stats + stats = self.get_event_stats() + print(f"[{self.name}] Event Bus Stats:") + print(f" - Total published: {stats.get('total_published', 0)}") + print(f" - Active subs: {stats.get('active_subscriptions', 0)}") + + def _publish_example_events(self): + """Publish some example events to demonstrate.""" + # Publish a skill gain + self.publish_typed(SkillGainEvent( + skill_name="Rifle", + skill_value=25.5, + gain_amount=0.01, + source="example_plugin" + )) + + # Publish some damage events + self.publish_typed(DamageEvent( + damage_amount=50.5, + damage_type="impact", + is_outgoing=True, + target_name="Berycled Young", + source="example_plugin" + )) + + self.publish_typed(DamageEvent( + damage_amount=150.0, + damage_type="penetration", + is_critical=True, + is_outgoing=True, + target_name="Daikiba", + source="example_plugin" + )) + + # Publish loot + self.publish_typed(LootEvent( + mob_name="Dragon", + items=[ + {"name": "Dragon Scale", "value": 15.0}, + {"name": "Animal Oil", "value": 0.05} + ], + total_tt_value=15.05, + source="example_plugin" + )) + + print(f"[{self.name}] Published example events") + + # ========== Event Handlers ========== + + def on_any_damage(self, event: DamageEvent): + """Handle all damage events.""" + direction = "dealt" if event.is_outgoing else "received" + crit = " CRITICAL" if event.is_critical else "" + print(f"[{self.name}] Damage {direction}: {event.damage_amount:.1f}{crit} to {event.target_name}") + + def on_big_damage(self, event: DamageEvent): + """Handle only high damage events (filtered).""" + self.big_hits.append(event) + print(f"[{self.name}] 💥 BIG HIT! {event.damage_amount:.1f} damage to {event.target_name}") + print(f"[{self.name}] Total big hits recorded: {len(self.big_hits)}") + + def on_combat_skill_gain(self, event: SkillGainEvent): + """Handle combat skill gains.""" + self.skill_gains.append(event) + print(f"[{self.name}] ⚔️ Skill up: {event.skill_name} +{event.gain_amount:.4f} = {event.skill_value:.4f}") + + def on_dragon_loot(self, event: LootEvent): + """Handle Dragon/Drake loot.""" + self.dragon_loot.append(event) + items_str = ", ".join(event.get_item_names()) + print(f"[{self.name}] 🐉 Dragon loot from {event.mob_name}: {items_str} (TT: {event.total_tt_value:.2f} PED)") + + def on_global_announcement(self, event: GlobalEvent): + """Handle global announcements.""" + item_str = f" with {event.item_name}" if event.item_name else "" + print(f"[{self.name}] 🌍 GLOBAL: {event.player_name} - {event.achievement_type.upper()}{item_str} ({event.value:.2f} PED)") + + def get_ui(self): + """Return simple info panel.""" + from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit + + widget = QWidget() + layout = QVBoxLayout() + + # Title + title = QLabel(f"

{self.name}

") + layout.addWidget(title) + + # Stats + stats_text = f""" + Recorded Events:
+ • Big Hits (≥100 dmg): {len(self.big_hits)}
+ • Combat Skill Gains: {len(self.skill_gains)}
+ • Dragon Loot: {len(self.dragon_loot)}
+
+ Active Subscriptions: {len(self._subscriptions)} + """ + stats_label = QLabel(stats_text) + stats_label.setWordWrap(True) + layout.addWidget(stats_label) + + # Recent events + layout.addWidget(QLabel("Recent Combat Events:")) + text_area = QTextEdit() + text_area.setReadOnly(True) + text_area.setMaximumHeight(200) + + # Get recent damage events from event bus + recent = self.get_recent_events(DamageEvent, count=10) + events_text = "\\n".join([ + f"• {e.damage_amount:.1f} dmg to {e.target_name}" + for e in reversed(recent) + ]) or "No recent damage events" + text_area.setText(events_text) + layout.addWidget(text_area) + + widget.setLayout(layout) + return widget + + def shutdown(self) -> None: + """Cleanup.""" + print(f"[{self.name}] Shutting down...") + # Unsubscribe from all typed events (handled by base class) + super().shutdown() diff --git a/plugins/game_reader/__init__.py b/plugins/game_reader/__init__.py new file mode 100644 index 0000000..0d896f8 --- /dev/null +++ b/plugins/game_reader/__init__.py @@ -0,0 +1,7 @@ +""" +Game Reader Plugin for EU-Utility +""" + +from .plugin import GameReaderPlugin + +__all__ = ["GameReaderPlugin"] diff --git a/plugins/game_reader/__pycache__/__init__.cpython-312.pyc b/plugins/game_reader/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..38c171b Binary files /dev/null and b/plugins/game_reader/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/game_reader/__pycache__/plugin.cpython-312.pyc b/plugins/game_reader/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..3c5b7f8 Binary files /dev/null and b/plugins/game_reader/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/game_reader/plugin.py b/plugins/game_reader/plugin.py new file mode 100644 index 0000000..093fd10 --- /dev/null +++ b/plugins/game_reader/plugin.py @@ -0,0 +1,261 @@ +""" +EU-Utility - OCR Scanner Plugin + +Reads text from in-game menus using OCR. +""" + +import subprocess +import platform +import tempfile +from pathlib import Path +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QTextEdit, QComboBox, + QFrame, QScrollArea, QGroupBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer +from PyQt6.QtGui import QPixmap, QImage + +from plugins.base_plugin import BasePlugin + + +class OCRScannerThread(QThread): + """Background thread for OCR scanning.""" + result_ready = pyqtSignal(str) + error_occurred = pyqtSignal(str) + + def __init__(self, region=None): + super().__init__() + self.region = region # (x, y, width, height) + + def run(self): + """Capture screen and perform OCR.""" + try: + system = platform.system() + + # Create temp file for screenshot + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + screenshot_path = tmp.name + + # Capture screenshot + if system == "Windows": + # Use PowerShell to capture screen + ps_cmd = f''' + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size) + $bitmap.Save("{screenshot_path}") + $graphics.Dispose() + $bitmap.Dispose() + ''' + subprocess.run(['powershell', '-Command', ps_cmd], capture_output=True, timeout=10) + + elif system == "Linux": + # Use gnome-screenshot or import + try: + subprocess.run(['gnome-screenshot', '-f', screenshot_path], + capture_output=True, timeout=10) + except: + subprocess.run(['import', '-window', 'root', screenshot_path], + capture_output=True, timeout=10) + + # Perform OCR + text = self._perform_ocr(screenshot_path) + + # Clean up + Path(screenshot_path).unlink(missing_ok=True) + + self.result_ready.emit(text) + + except Exception as e: + self.error_occurred.emit(str(e)) + + def _perform_ocr(self, image_path): + """Perform OCR on image.""" + try: + # Try easyocr first + import easyocr + reader = easyocr.Reader(['en']) + results = reader.readtext(image_path) + text = '\n'.join([result[1] for result in results]) + return text if text else "No text detected" + except: + pass + + try: + # Try pytesseract + import pytesseract + from PIL import Image + image = Image.open(image_path) + text = pytesseract.image_to_string(image) + return text if text.strip() else "No text detected" + except: + pass + + return "OCR not available. Install: pip install easyocr or pytesseract" + + +class GameReaderPlugin(BasePlugin): + """Read in-game menus and text using OCR.""" + + name = "Game Reader" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "OCR scanner for in-game menus and text" + hotkey = "ctrl+shift+r" # R for Read + + def initialize(self): + """Setup game reader.""" + self.scan_thread = None + self.last_result = "" + + def get_ui(self): + """Create game reader UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("📷 Game Reader (OCR)") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Info + info = QLabel("Capture in-game menus and read the text") + info.setStyleSheet("color: rgba(255, 255, 255, 150); font-size: 12px;") + layout.addWidget(info) + + # Scan button + scan_btn = QPushButton("Capture Screen") + scan_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 15px; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background-color: #5aafff; + } + QPushButton:pressed { + background-color: #3a8eef; + } + """) + scan_btn.clicked.connect(self._capture_screen) + layout.addWidget(scan_btn) + + # Status + self.status_label = QLabel("Ready to capture") + self.status_label.setStyleSheet("color: #666; font-size: 11px;") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.status_label) + + # Results area + results_frame = QFrame() + results_frame.setStyleSheet(""" + QFrame { + background-color: rgba(0, 0, 0, 50); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 20); + } + """) + results_layout = QVBoxLayout(results_frame) + results_layout.setContentsMargins(10, 10, 10, 10) + + results_label = QLabel("Captured Text:") + results_label.setStyleSheet("color: rgba(255, 255, 255, 150); font-size: 12px;") + results_layout.addWidget(results_label) + + self.result_text = QTextEdit() + self.result_text.setPlaceholderText("Captured text will appear here...") + self.result_text.setStyleSheet(""" + QTextEdit { + background-color: rgba(30, 30, 30, 100); + color: white; + border: none; + border-radius: 6px; + padding: 8px; + font-size: 13px; + } + """) + self.result_text.setMaximumHeight(150) + results_layout.addWidget(self.result_text) + + # Copy button + copy_btn = QPushButton("Copy Text") + copy_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255, 255, 255, 20); + color: white; + padding: 8px; + border: none; + border-radius: 6px; + } + QPushButton:hover { + background-color: rgba(255, 255, 255, 30); + } + """) + copy_btn.clicked.connect(self._copy_text) + results_layout.addWidget(copy_btn) + + layout.addWidget(results_frame) + + # Common uses + uses_label = QLabel("Common Uses:") + uses_label.setStyleSheet("color: rgba(255, 255, 255, 150); font-size: 11px; margin-top: 10px;") + layout.addWidget(uses_label) + + uses_text = QLabel( + "• Read NPC dialogue\n" + "• Capture mission text\n" + "• Extract item stats\n" + "• Read shop prices" + ) + uses_text.setStyleSheet("color: rgba(255, 255, 255, 100); font-size: 11px;") + layout.addWidget(uses_text) + + layout.addStretch() + + return widget + + def _capture_screen(self): + """Capture screen and perform OCR.""" + self.status_label.setText("Capturing...") + self.status_label.setStyleSheet("color: #4a9eff;") + + # Start scan thread + self.scan_thread = OCRScannerThread() + self.scan_thread.result_ready.connect(self._on_result) + self.scan_thread.error_occurred.connect(self._on_error) + self.scan_thread.start() + + def _on_result(self, text): + """Handle OCR result.""" + self.result_text.setText(text) + self.last_result = text + self.status_label.setText(f"✅ Captured {len(text)} characters") + self.status_label.setStyleSheet("color: #4caf50;") + + def _on_error(self, error): + """Handle OCR error.""" + self.status_label.setText(f"❌ Error: {error}") + self.status_label.setStyleSheet("color: #f44336;") + + def _copy_text(self): + """Copy text to clipboard.""" + from PyQt6.QtWidgets import QApplication + clipboard = QApplication.clipboard() + clipboard.setText(self.result_text.toPlainText()) + self.status_label.setText("📋 Copied to clipboard!") + + def on_hotkey(self): + """Capture on hotkey.""" + self._capture_screen() diff --git a/plugins/game_reader_test/__init__.py b/plugins/game_reader_test/__init__.py new file mode 100644 index 0000000..401c3cf --- /dev/null +++ b/plugins/game_reader_test/__init__.py @@ -0,0 +1,2 @@ +"""Game Reader Test Plugin.""" +from .plugin import GameReaderTestPlugin diff --git a/plugins/game_reader_test/__pycache__/plugin.cpython-312.pyc b/plugins/game_reader_test/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..2ab0232 Binary files /dev/null and b/plugins/game_reader_test/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/game_reader_test/plugin.py b/plugins/game_reader_test/plugin.py new file mode 100644 index 0000000..c47532f --- /dev/null +++ b/plugins/game_reader_test/plugin.py @@ -0,0 +1,1197 @@ +""" +EU-Utility - Game Reader Test Plugin + +Debug and test tool for OCR and game reading functionality. +Tests screen capture, OCR accuracy, and text extraction. +""" + +import re +from pathlib import Path +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, + QLabel, QPushButton, QComboBox, QCheckBox, + QSpinBox, QGroupBox, QSplitter, QFrame, + QTabWidget, QLineEdit, QProgressBar, + QFileDialog, QMessageBox, QTableWidget, + QTableWidgetItem, QHeaderView +) +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal +from PyQt6.QtGui import QPixmap, QImage, QColor + +from plugins.base_plugin import BasePlugin + + +class OCRTestThread(QThread): + """Background thread for OCR testing.""" + result_ready = pyqtSignal(dict) + progress_update = pyqtSignal(int, str) + + def __init__(self, region=None, backend='auto'): + super().__init__() + self.region = region + self.backend = backend + + def run(self): + """Run OCR test.""" + import time + results = { + 'success': False, + 'text': '', + 'backend_used': '', + 'processing_time': 0, + 'error': None + } + + try: + start_time = time.time() + self.progress_update.emit(10, "Capturing screen...") + + # Capture screen + from PIL import Image, ImageGrab + if self.region: + screenshot = ImageGrab.grab(bbox=self.region) + else: + screenshot = ImageGrab.grab() + + self.progress_update.emit(30, "Running OCR...") + + # Try OCR backends + text = "" + backend_used = "none" + + if self.backend in ('auto', 'easyocr'): + try: + import easyocr + import numpy as np + self.progress_update.emit(50, "Loading EasyOCR...") + reader = easyocr.Reader(['en'], gpu=False, verbose=False) + self.progress_update.emit(70, "Processing with EasyOCR...") + # Convert PIL Image to numpy array + screenshot_np = np.array(screenshot) + ocr_result = reader.readtext( + screenshot_np, + detail=0, + paragraph=True + ) + text = '\n'.join(ocr_result) + backend_used = "easyocr" + except Exception as e: + if self.backend == 'easyocr': + raise e + + if not text and self.backend in ('auto', 'tesseract'): + try: + import pytesseract + self.progress_update.emit(70, "Processing with Tesseract...") + text = pytesseract.image_to_string(screenshot) + backend_used = "tesseract" + except Exception as e: + if self.backend == 'tesseract': + raise e + + if not text and self.backend in ('auto', 'paddle'): + try: + from paddleocr import PaddleOCR + import numpy as np + self.progress_update.emit(70, "Processing with PaddleOCR...") + # Try with show_log, fall back without for compatibility + try: + ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False) + except TypeError: + ocr = PaddleOCR(use_angle_cls=True, lang='en') + # Convert PIL to numpy + screenshot_np = np.array(screenshot) + result = ocr.ocr(screenshot_np, cls=True) + if result and result[0]: + texts = [line[1][0] for line in result[0]] + text = '\n'.join(texts) + backend_used = "paddleocr" + except Exception as e: + if self.backend == 'paddle': + raise e + + processing_time = time.time() - start_time + + results.update({ + 'success': True, + 'text': text or "No text detected", + 'backend_used': backend_used, + 'processing_time': processing_time + }) + + self.progress_update.emit(100, "Complete!") + + except Exception as e: + results['error'] = str(e) + self.progress_update.emit(100, f"Error: {e}") + + self.result_ready.emit(results) + + +class GameReaderTestPlugin(BasePlugin): + """Test and debug tool for game reading/OCR functionality.""" + + name = "Game Reader Test" + version = "1.0.0" + author = "EU-Utility" + description = "Debug tool for testing OCR and screen reading" + + # Dependencies for OCR functionality + dependencies = { + 'pip': ['pillow', 'numpy'], + 'optional': { + 'easyocr': 'Best OCR accuracy, auto-downloads models', + 'pytesseract': 'Alternative OCR engine', + 'paddleocr': 'Advanced OCR with layout detection' + } + } + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + self.test_history = [] + self.max_history = 50 + self.ocr_thread = None + + def initialize(self): + """Initialize plugin.""" + self.log_info("Game Reader Test initialized") + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(12) + + # Header + header = QLabel("📷 Game Reader Test & Debug Tool") + header.setStyleSheet("font-size: 16px; font-weight: bold; color: #ff8c42;") + layout.addWidget(header) + + # Create tabs + tabs = QTabWidget() + + # Tab 1: Quick Test + tabs.addTab(self._create_quick_test_tab(), "Quick Test") + + # Tab 2: File Test (NEW) + tabs.addTab(self._create_file_test_tab(), "File Test") + + # Tab 3: Region Test + tabs.addTab(self._create_region_test_tab(), "Region Test") + + # Tab 3: History + tabs.addTab(self._create_history_tab(), "History") + + # Tab 5: Skills Parser (NEW) + tabs.addTab(self._create_skills_parser_tab(), "Skills Parser") + + # Tab 6: Calibration + tabs.addTab(self._create_calibration_tab(), "Calibration") + + layout.addWidget(tabs, 1) + + # Status bar + status_frame = QFrame() + status_frame.setStyleSheet("background-color: #1a1f2e; border-radius: 6px; padding: 8px;") + status_layout = QHBoxLayout(status_frame) + + self.status_label = QLabel("Ready - Select a tab to begin testing") + self.status_label.setStyleSheet("color: #4ecdc4;") + status_layout.addWidget(self.status_label) + + layout.addWidget(status_frame) + + return widget + + def _create_quick_test_tab(self): + """Create quick test tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Info + info = QLabel("Quick OCR Test - Captures full screen and extracts text") + info.setStyleSheet("color: #888;") + layout.addWidget(info) + + # Backend selection + backend_layout = QHBoxLayout() + backend_layout.addWidget(QLabel("OCR Backend:")) + self.backend_combo = QComboBox() + self.backend_combo.addItems(["Auto (try all)", "EasyOCR", "Tesseract", "PaddleOCR"]) + backend_layout.addWidget(self.backend_combo) + backend_layout.addStretch() + layout.addLayout(backend_layout) + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setStyleSheet(""" + QProgressBar { + border: 1px solid #333; + border-radius: 4px; + text-align: center; + } + QProgressBar::chunk { + background-color: #ff8c42; + } + """) + layout.addWidget(self.progress_bar) + + # Test button + self.test_btn = QPushButton("▶ Run OCR Test") + self.test_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: #141f23; + font-weight: bold; + padding: 12px; + font-size: 14px; + } + QPushButton:hover { + background-color: #ffa05c; + } + """) + self.test_btn.clicked.connect(self._run_quick_test) + layout.addWidget(self.test_btn) + + # Results + results_group = QGroupBox("OCR Results") + results_layout = QVBoxLayout(results_group) + + self.results_text = QTextEdit() + self.results_text.setReadOnly(True) + self.results_text.setPlaceholderText("OCR results will appear here...") + self.results_text.setStyleSheet(""" + QTextEdit { + background-color: #0d1117; + color: #c9d1d9; + font-family: Consolas, monospace; + font-size: 12px; + } + """) + results_layout.addWidget(self.results_text) + + # Stats + self.stats_label = QLabel("Backend: - | Time: - | Status: Waiting") + self.stats_label.setStyleSheet("color: #888;") + results_layout.addWidget(self.stats_label) + + layout.addWidget(results_group) + + # Save buttons + btn_layout = QHBoxLayout() + + save_text_btn = QPushButton("💾 Save Text") + save_text_btn.clicked.connect(self._save_text) + btn_layout.addWidget(save_text_btn) + + copy_btn = QPushButton("📋 Copy to Clipboard") + copy_btn.clicked.connect(self._copy_to_clipboard) + btn_layout.addWidget(copy_btn) + + clear_btn = QPushButton("🗑 Clear") + clear_btn.clicked.connect(self._clear_results) + btn_layout.addWidget(clear_btn) + + btn_layout.addStretch() + layout.addLayout(btn_layout) + + layout.addStretch() + return tab + + def _create_file_test_tab(self): + """Create file-based OCR test tab for testing with saved screenshots.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Info + info = QLabel("Test OCR on an image file (PNG, JPG, BMP)") + info.setStyleSheet("color: #888;") + layout.addWidget(info) + + # File selection + file_layout = QHBoxLayout() + + self.file_path_label = QLabel("No file selected") + self.file_path_label.setStyleSheet("color: #aaa; padding: 8px; background-color: #1a1f2e; border-radius: 4px;") + file_layout.addWidget(self.file_path_label, 1) + + browse_btn = QPushButton("Browse...") + browse_btn.setStyleSheet(""" + QPushButton { + background-color: #4ecdc4; + color: #141f23; + font-weight: bold; + padding: 8px 16px; + } + """) + browse_btn.clicked.connect(self._browse_image_file) + file_layout.addWidget(browse_btn) + + layout.addLayout(file_layout) + + # Backend selection + backend_layout = QHBoxLayout() + backend_layout.addWidget(QLabel("OCR Backend:")) + self.file_backend_combo = QComboBox() + self.file_backend_combo.addItems(["Auto (try all)", "EasyOCR", "Tesseract", "PaddleOCR"]) + backend_layout.addWidget(self.file_backend_combo) + backend_layout.addStretch() + layout.addLayout(backend_layout) + + # Test button + file_test_btn = QPushButton("▶ Run OCR on File") + file_test_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: #141f23; + font-weight: bold; + padding: 12px; + font-size: 14px; + } + """) + file_test_btn.clicked.connect(self._run_file_test) + layout.addWidget(file_test_btn) + + # Results + results_group = QGroupBox("OCR Results") + results_layout = QVBoxLayout(results_group) + + self.file_results_text = QTextEdit() + self.file_results_text.setReadOnly(True) + self.file_results_text.setPlaceholderText("OCR results will appear here...") + self.file_results_text.setStyleSheet(""" + QTextEdit { + background-color: #0d1117; + color: #c9d1d9; + font-family: Consolas, monospace; + font-size: 12px; + } + """) + results_layout.addWidget(self.file_results_text) + + # Stats + self.file_stats_label = QLabel("File: - | Backend: - | Status: Waiting") + self.file_stats_label.setStyleSheet("color: #888;") + results_layout.addWidget(self.file_stats_label) + + layout.addWidget(results_group, 1) + + layout.addStretch() + return tab + + def _create_region_test_tab(self): + """Create region test tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + info = QLabel("Test OCR on specific screen region") + info.setStyleSheet("color: #888;") + layout.addWidget(info) + + # Region input + region_group = QGroupBox("Screen Region (pixels)") + region_layout = QHBoxLayout(region_group) + + self.x_input = QSpinBox() + self.x_input.setRange(0, 10000) + self.x_input.setValue(100) + region_layout.addWidget(QLabel("X:")) + region_layout.addWidget(self.x_input) + + self.y_input = QSpinBox() + self.y_input.setRange(0, 10000) + self.y_input.setValue(100) + region_layout.addWidget(QLabel("Y:")) + region_layout.addWidget(self.y_input) + + self.w_input = QSpinBox() + self.w_input.setRange(100, 10000) + self.w_input.setValue(400) + region_layout.addWidget(QLabel("Width:")) + region_layout.addWidget(self.w_input) + + self.h_input = QSpinBox() + self.h_input.setRange(100, 10000) + self.h_input.setValue(300) + region_layout.addWidget(QLabel("Height:")) + region_layout.addWidget(self.h_input) + + layout.addWidget(region_group) + + # Presets + preset_layout = QHBoxLayout() + preset_layout.addWidget(QLabel("Quick Presets:")) + + presets = [ + ("Chat Window", 10, 800, 600, 200), + ("Skills Window", 100, 100, 500, 400), + ("Inventory", 1200, 200, 600, 500), + ("Mission Tracker", 1600, 100, 300, 600), + ] + + for name, x, y, w, h in presets: + btn = QPushButton(name) + btn.clicked.connect(lambda checked, px=x, py=y, pw=w, ph=h: self._set_region(px, py, pw, ph)) + preset_layout.addWidget(btn) + + preset_layout.addStretch() + layout.addLayout(preset_layout) + + # Test button + region_test_btn = QPushButton("▶ Test Region OCR") + region_test_btn.setStyleSheet(""" + QPushButton { + background-color: #4ecdc4; + color: #141f23; + font-weight: bold; + padding: 10px; + } + """) + region_test_btn.clicked.connect(self._run_region_test) + layout.addWidget(region_test_btn) + + # Results + self.region_results = QTextEdit() + self.region_results.setReadOnly(True) + self.region_results.setPlaceholderText("Region OCR results will appear here...") + self.region_results.setStyleSheet(""" + QTextEdit { + background-color: #0d1117; + color: #c9d1d9; + font-family: Consolas, monospace; + } + """) + layout.addWidget(self.region_results) + + layout.addStretch() + return tab + + def _create_history_tab(self): + """Create history tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + info = QLabel("History of OCR tests") + info.setStyleSheet("color: #888;") + layout.addWidget(info) + + self.history_text = QTextEdit() + self.history_text.setReadOnly(True) + self.history_text.setStyleSheet(""" + QTextEdit { + background-color: #0d1117; + color: #c9d1d9; + } + """) + layout.addWidget(self.history_text) + + # Controls + btn_layout = QHBoxLayout() + + refresh_btn = QPushButton("🔄 Refresh") + refresh_btn.clicked.connect(self._update_history) + btn_layout.addWidget(refresh_btn) + + clear_hist_btn = QPushButton("🗑 Clear History") + clear_hist_btn.clicked.connect(self._clear_history) + btn_layout.addWidget(clear_hist_btn) + + btn_layout.addStretch() + layout.addLayout(btn_layout) + + return tab + + def _create_skills_parser_tab(self): + """Create skills parser tab for testing skill window OCR.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + info = QLabel("📊 Skills Window Parser - Extract skills from the EU Skills window") + info.setStyleSheet("color: #888;") + layout.addWidget(info) + + # Instructions + instructions = QLabel( + "1. Open your Skills window in Entropia Universe\n" + "2. Click 'Capture Skills Window' below\n" + "3. View parsed skills in the table" + ) + instructions.setStyleSheet("color: #666; font-size: 11px;") + layout.addWidget(instructions) + + # Capture button + capture_btn = QPushButton("📷 Capture Skills Window") + capture_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: #141f23; + font-weight: bold; + padding: 12px; + font-size: 14px; + } + """) + capture_btn.clicked.connect(self._capture_and_parse_skills) + layout.addWidget(capture_btn) + + # Results table + from PyQt6.QtWidgets import QTableWidget, QTableWidgetItem, QHeaderView + + self.skills_table = QTableWidget() + self.skills_table.setColumnCount(3) + self.skills_table.setHorizontalHeaderLabels(["Skill Name", "Rank", "Points"]) + self.skills_table.horizontalHeader().setStretchLastSection(True) + self.skills_table.setStyleSheet(""" + QTableWidget { + background-color: #0d1117; + border: 1px solid #333; + } + QTableWidget::item { + padding: 6px; + color: #c9d1d9; + } + QHeaderView::section { + background-color: #1a1f2e; + color: #ff8c42; + padding: 8px; + font-weight: bold; + } + """) + layout.addWidget(self.skills_table, 1) + + # Stats label + self.skills_stats_label = QLabel("No skills captured yet") + self.skills_stats_label.setStyleSheet("color: #888;") + layout.addWidget(self.skills_stats_label) + + # Raw text view + raw_group = QGroupBox("Raw OCR Text (for debugging)") + raw_layout = QVBoxLayout(raw_group) + + self.skills_raw_text = QTextEdit() + self.skills_raw_text.setReadOnly(True) + self.skills_raw_text.setMaximumHeight(150) + self.skills_raw_text.setStyleSheet(""" + QTextEdit { + background-color: #0d1117; + color: #666; + font-family: Consolas, monospace; + font-size: 10px; + } + """) + raw_layout.addWidget(self.skills_raw_text) + layout.addWidget(raw_group) + + layout.addStretch() + return tab + + def _capture_and_parse_skills(self): + """Capture screen and parse skills.""" + from PyQt6.QtCore import Qt + + self.skills_stats_label.setText("Capturing...") + self.skills_stats_label.setStyleSheet("color: #4ecdc4;") + + # Run in thread to not block UI + from threading import Thread + + def capture_and_parse(): + try: + from PIL import ImageGrab + import re + from datetime import datetime + + # Capture screen + screenshot = ImageGrab.grab() + + # Run OCR + text = "" + try: + import easyocr + reader = easyocr.Reader(['en'], gpu=False, verbose=False) + import numpy as np + result = reader.readtext(np.array(screenshot), detail=0, paragraph=False) + text = '\n'.join(result) + except Exception as e: + # Fallback to raw OCR + text = str(e) + + # Parse skills + skills = self._parse_skills_from_text(text) + + # Update UI + from PyQt6.QtCore import QMetaObject, Qt, Q_ARG + QMetaObject.invokeMethod( + self.skills_raw_text, + "setPlainText", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, text) + ) + + # Update table + QMetaObject.invokeMethod( + self, "_update_skills_table", + Qt.ConnectionType.QueuedConnection, + Q_ARG(object, skills) + ) + + # Update stats + stats_text = f"Found {len(skills)} skills" + QMetaObject.invokeMethod( + self.skills_stats_label, + "setText", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, stats_text) + ) + QMetaObject.invokeMethod( + self.skills_stats_label, + "setStyleSheet", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, "color: #4ecdc4;") + ) + + except Exception as e: + from PyQt6.QtCore import QMetaObject, Qt, Q_ARG + QMetaObject.invokeMethod( + self.skills_stats_label, + "setText", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Error: {str(e)}") + ) + QMetaObject.invokeMethod( + self.skills_stats_label, + "setStyleSheet", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, "color: #ff6b6b;") + ) + + thread = Thread(target=capture_and_parse) + 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', '') + + lines = text.split('\n') + + for line in lines: + line = line.strip() + if not line or len(line) < 10: + continue + + # Pattern: SkillName Rank Points + match = re.search( + rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)', + line, re.IGNORECASE + ) + + if match: + skill_name = match.group(1).strip() + rank = match.group(2) + points = int(match.group(3)) + + # Clean skill name - remove "Skill" prefix + skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE) + + if points > 0 and skill_name: + skills[skill_name] = {'rank': rank, 'points': points} + + return skills + + def _update_skills_table(self, skills): + """Update the skills table with parsed data.""" + self.skills_table.setRowCount(len(skills)) + + for i, (skill_name, data) in enumerate(sorted(skills.items())): + self.skills_table.setItem(i, 0, QTableWidgetItem(skill_name)) + self.skills_table.setItem(i, 1, QTableWidgetItem(data['rank'])) + self.skills_table.setItem(i, 2, QTableWidgetItem(str(data['points']))) + + self.skills_table.resizeColumnsToContents() + + def _create_calibration_tab(self): + """Create calibration tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + info = QLabel("Calibration tools for optimizing OCR accuracy") + info.setStyleSheet("color: #888;") + layout.addWidget(info) + + # DPI awareness + dpi_group = QGroupBox("Display Settings") + dpi_layout = QVBoxLayout(dpi_group) + + self.dpi_label = QLabel("Detecting display DPI...") + dpi_layout.addWidget(self.dpi_label) + + detect_dpi_btn = QPushButton("Detect Display Settings") + detect_dpi_btn.clicked.connect(self._detect_display_settings) + dpi_layout.addWidget(detect_dpi_btn) + + layout.addWidget(dpi_group) + + # Backend status + backend_group = QGroupBox("OCR Backend Status") + backend_layout = QVBoxLayout(backend_group) + + self.backend_status = QTextEdit() + self.backend_status.setReadOnly(True) + self.backend_status.setMaximumHeight(200) + self._check_backends() + backend_layout.addWidget(self.backend_status) + + # Install buttons + install_layout = QHBoxLayout() + + install_easyocr_btn = QPushButton("📦 Install EasyOCR") + install_easyocr_btn.setStyleSheet(""" + QPushButton { + background-color: #4ecdc4; + color: #141f23; + font-weight: bold; + padding: 8px; + } + """) + install_easyocr_btn.clicked.connect(self._install_easyocr) + install_layout.addWidget(install_easyocr_btn) + + install_tesseract_btn = QPushButton("📦 Install Tesseract Package") + install_tesseract_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: #141f23; + font-weight: bold; + padding: 8px; + } + """) + install_tesseract_btn.clicked.connect(self._install_pytesseract) + install_layout.addWidget(install_tesseract_btn) + + detect_tesseract_btn = QPushButton("🔍 Auto-Detect Tesseract") + detect_tesseract_btn.setStyleSheet(""" + QPushButton { + background-color: #a0aec0; + color: #141f23; + font-weight: bold; + padding: 8px; + } + """) + detect_tesseract_btn.clicked.connect(self._auto_detect_tesseract) + install_layout.addWidget(detect_tesseract_btn) + + install_paddle_btn = QPushButton("📦 Install PaddleOCR") + install_paddle_btn.setStyleSheet(""" + QPushButton { + background-color: #4a5568; + color: white; + font-weight: bold; + padding: 8px; + } + """) + install_paddle_btn.clicked.connect(self._install_paddleocr) + install_layout.addWidget(install_paddle_btn) + + backend_layout.addLayout(install_layout) + layout.addWidget(backend_group) + + # Tips + tips_group = QGroupBox("Tips for Best Results") + tips_layout = QVBoxLayout(tips_group) + + tips_text = QLabel(""" + • Make sure text is clearly visible and not blurry + • Use region selection to focus on specific text areas + • Higher resolution screens work better for OCR + • Close other windows to reduce background noise + • Ensure good contrast between text and background + """) + tips_text.setWordWrap(True) + tips_text.setStyleSheet("color: #aaa;") + tips_layout.addWidget(tips_text) + + layout.addWidget(tips_group) + + layout.addStretch() + return tab + + def _set_region(self, x, y, w, h): + """Set region values.""" + self.x_input.setValue(x) + self.y_input.setValue(y) + self.w_input.setValue(w) + self.h_input.setValue(h) + + def _browse_image_file(self): + """Browse for an image file to test OCR on.""" + file_path, _ = QFileDialog.getOpenFileName( + None, + "Select Image File", + "", + "Images (*.png *.jpg *.jpeg *.bmp *.tiff);;All Files (*)" + ) + if file_path: + self.selected_file_path = file_path + self.file_path_label.setText(Path(file_path).name) + self.file_path_label.setStyleSheet("color: #4ecdc4; padding: 8px; background-color: #1a1f2e; border-radius: 4px;") + + def _run_file_test(self): + """Run OCR on the selected image file.""" + if not hasattr(self, 'selected_file_path') or not self.selected_file_path: + QMessageBox.warning(None, "No File", "Please select an image file first!") + return + + backend_map = { + 0: 'auto', + 1: 'easyocr', + 2: 'tesseract', + 3: 'paddle' + } + backend = backend_map.get(self.file_backend_combo.currentIndex(), 'auto') + + self.file_results_text.setPlainText("Processing...") + self.file_stats_label.setText("Processing...") + + # Run OCR in a thread + from threading import Thread + + def process_file(): + try: + from PIL import Image + import time + + start_time = time.time() + + # Load image + image = Image.open(self.selected_file_path) + + # Try OCR backends + text = "" + backend_used = "none" + + if backend in ('auto', 'easyocr'): + try: + import easyocr + import numpy as np + reader = easyocr.Reader(['en'], gpu=False, verbose=False) + # Convert PIL Image to numpy array for EasyOCR + image_np = np.array(image) + ocr_result = reader.readtext( + image_np, + detail=0, + paragraph=True + ) + text = '\n'.join(ocr_result) + backend_used = "easyocr" + except Exception as e: + if backend == 'easyocr': + raise e + + if not text and backend in ('auto', 'tesseract'): + try: + import pytesseract + text = pytesseract.image_to_string(image) + backend_used = "tesseract" + except Exception as e: + if backend == 'tesseract': + error_msg = str(e) + if "tesseract is not installed" in error_msg.lower() or "not in your path" in error_msg.lower(): + raise Exception( + "Tesseract is not installed.\n\n" + "To use Tesseract OCR:\n" + "1. Download from: https://github.com/UB-Mannheim/tesseract/wiki\n" + "2. Install to C:\\Program Files\\Tesseract-OCR\\\n" + "3. Add to PATH or restart EU-Utility\n\n" + "Alternatively, use EasyOCR (auto-installs): pip install easyocr" + ) + raise e + + if not text and backend in ('auto', 'paddle'): + try: + from paddleocr import PaddleOCR + # Try without show_log argument for compatibility + try: + ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False) + except TypeError: + # Older version without show_log + ocr = PaddleOCR(use_angle_cls=True, lang='en') + # Convert PIL to numpy for PaddleOCR + import numpy as np + image_np = np.array(image) + result = ocr.ocr(image_np, cls=True) + if result and result[0]: + texts = [line[1][0] for line in result[0]] + text = '\n'.join(texts) + backend_used = "paddleocr" + except Exception as e: + if backend == 'paddle': + raise e + + processing_time = time.time() - start_time + + # Update UI (thread-safe via Qt signals would be better, but this works for simple case) + from PyQt6.QtCore import QMetaObject, Qt, Q_ARG + QMetaObject.invokeMethod( + self.file_results_text, + "setPlainText", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, text if text else "No text detected") + ) + QMetaObject.invokeMethod( + self.file_stats_label, + "setText", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"File: {Path(self.selected_file_path).name} | Backend: {backend_used} | Time: {processing_time:.2f}s") + ) + + except Exception as e: + from PyQt6.QtCore import QMetaObject, Qt, Q_ARG + QMetaObject.invokeMethod( + self.file_results_text, + "setPlainText", + Qt.ConnectionType.QueuedConnection, + Q_ARG(str, f"Error: {str(e)}") + ) + + thread = Thread(target=process_file) + thread.daemon = True + thread.start() + + def _run_quick_test(self): + """Run quick OCR test.""" + if self.ocr_thread and self.ocr_thread.isRunning(): + return + + backend_map = { + 0: 'auto', + 1: 'easyocr', + 2: 'tesseract', + 3: 'paddle' + } + backend = backend_map.get(self.backend_combo.currentIndex(), 'auto') + + self.test_btn.setEnabled(False) + self.test_btn.setText("⏳ Running...") + self.progress_bar.setValue(0) + + self.ocr_thread = OCRTestThread(backend=backend) + self.ocr_thread.progress_update.connect(self._update_progress) + self.ocr_thread.result_ready.connect(self._on_ocr_complete) + self.ocr_thread.start() + + def _update_progress(self, value, message): + """Update progress bar.""" + self.progress_bar.setValue(value) + self.status_label.setText(message) + + def _on_ocr_complete(self, results): + """Handle OCR completion.""" + self.test_btn.setEnabled(True) + self.test_btn.setText("▶ Run OCR Test") + + if results['success']: + self.results_text.setPlainText(results['text']) + self.stats_label.setText( + f"Backend: {results['backend_used']} | " + f"Time: {results['processing_time']:.2f}s | " + f"Status: ✅ Success" + ) + + # Add to history + self._add_to_history(results) + else: + self.results_text.setPlainText(f"Error: {results.get('error', 'Unknown error')}") + self.stats_label.setText(f"Backend: {results.get('backend_used', '-')} | Status: ❌ Failed") + + def _run_region_test(self): + """Run region OCR test.""" + region = ( + self.x_input.value(), + self.y_input.value(), + self.w_input.value(), + self.h_input.value() + ) + + self.region_results.setPlainText("Running OCR on region...") + + # Use api's ocr_capture if available + try: + result = self.ocr_capture(region=region) + self.region_results.setPlainText(result.get('text', 'No text detected')) + except Exception as e: + self.region_results.setPlainText(f"Error: {e}") + + def _save_text(self): + """Save OCR text to file.""" + text = self.results_text.toPlainText() + if not text: + QMessageBox.warning(None, "Save Error", "No text to save!") + return + + file_path, _ = QFileDialog.getSaveFileName( + None, "Save OCR Text", "ocr_result.txt", "Text Files (*.txt)" + ) + if file_path: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(text) + self.status_label.setText(f"Saved to {file_path}") + + def _copy_to_clipboard(self): + """Copy text to clipboard.""" + text = self.results_text.toPlainText() + if text: + self.copy_to_clipboard(text) + self.status_label.setText("Copied to clipboard!") + + def _clear_results(self): + """Clear results.""" + self.results_text.clear() + self.stats_label.setText("Backend: - | Time: - | Status: Waiting") + self.progress_bar.setValue(0) + + def _add_to_history(self, results): + """Add result to history.""" + from datetime import datetime + entry = { + 'time': datetime.now().strftime("%H:%M:%S"), + 'backend': results['backend_used'], + 'time_taken': results['processing_time'], + 'text_preview': results['text'][:100] + "..." if len(results['text']) > 100 else results['text'] + } + self.test_history.insert(0, entry) + if len(self.test_history) > self.max_history: + self.test_history = self.test_history[:self.max_history] + self._update_history() + + def _update_history(self): + """Update history display.""" + lines = [] + for entry in self.test_history: + lines.append(f"[{entry['time']}] {entry['backend']} ({entry['time_taken']:.2f}s)") + lines.append(f" {entry['text_preview']}") + lines.append("") + self.history_text.setPlainText('\n'.join(lines) if lines else "No history yet") + + def _clear_history(self): + """Clear history.""" + self.test_history.clear() + self._update_history() + + def _detect_display_settings(self): + """Detect display settings.""" + try: + from PyQt6.QtWidgets import QApplication + from PyQt6.QtGui import QScreen + + app = QApplication.instance() + if app: + screens = app.screens() + info = [] + for i, screen in enumerate(screens): + geo = screen.geometry() + dpi = screen.logicalDotsPerInch() + info.append(f"Screen {i+1}: {geo.width()}x{geo.height()} @ {dpi:.0f} DPI") + self.dpi_label.setText('\n'.join(info)) + except Exception as e: + self.dpi_label.setText(f"Error: {e}") + + def _install_easyocr(self): + """Install EasyOCR backend.""" + from core.ocr_backend_manager import get_ocr_backend_manager + manager = get_ocr_backend_manager() + success, message = manager.install_backend('easyocr') + if success: + self.notify_success("Installation Complete", message) + else: + self.notify_error("Installation Failed", message) + self._check_backends() + + def _install_pytesseract(self): + """Install pytesseract Python package.""" + from core.ocr_backend_manager import get_ocr_backend_manager + manager = get_ocr_backend_manager() + success, message = manager.install_backend('tesseract') + if success: + self.notify_success("Installation Complete", message + "\n\nNote: You also need to install the Tesseract binary from:\nhttps://github.com/UB-Mannheim/tesseract/wiki") + else: + self.notify_error("Installation Failed", message) + self._check_backends() + + def _install_paddleocr(self): + """Install PaddleOCR backend.""" + from core.ocr_backend_manager import get_ocr_backend_manager + manager = get_ocr_backend_manager() + success, message = manager.install_backend('paddleocr') + if success: + self.notify_success("Installation Complete", message) + else: + self.notify_error("Installation Failed", message) + self._check_backends() + + def _auto_detect_tesseract(self): + """Auto-detect Tesseract from registry/paths.""" + from core.ocr_backend_manager import get_ocr_backend_manager + manager = get_ocr_backend_manager() + if manager.auto_configure_tesseract(): + self.notify_success("Tesseract Found", f"Auto-configured Tesseract at:\n{manager.backends['tesseract']['path']}") + else: + self.notify_warning("Not Found", "Could not find Tesseract installation.\n\nPlease install from:\nhttps://github.com/UB-Mannheim/tesseract/wiki") + self._check_backends() + + def _check_backends(self): + """Check OCR backend availability with install buttons.""" + from core.ocr_backend_manager import get_ocr_backend_manager + + manager = get_ocr_backend_manager() + statuses = [] + + # EasyOCR + easyocr_status = manager.get_backend_status('easyocr') + if easyocr_status['available']: + statuses.append("✅ EasyOCR - Available (recommended)") + else: + statuses.append("❌ EasyOCR - Not installed\n Click 'Install EasyOCR' button below") + + # Tesseract + tesseract_status = manager.get_backend_status('tesseract') + if tesseract_status['available']: + path_info = f" at {tesseract_status['path']}" if tesseract_status['path'] else "" + statuses.append(f"✅ Tesseract - Available{path_info}") + elif tesseract_status['installed']: + statuses.append("⚠️ Tesseract - Python package installed but binary not found\n Click 'Auto-Detect Tesseract' or install binary from:\n https://github.com/UB-Mannheim/tesseract/wiki") + else: + statuses.append("❌ Tesseract - Not installed\n Click 'Install Tesseract Package' then install binary") + + # PaddleOCR + paddle_status = manager.get_backend_status('paddleocr') + if paddle_status['available']: + statuses.append("✅ PaddleOCR - Available") + else: + statuses.append("❌ PaddleOCR - Not installed\n Click 'Install PaddleOCR' button below") + + statuses.append("\n💡 Recommendation: EasyOCR is easiest (auto-downloads models)") + + self.backend_status.setPlainText('\n'.join(statuses)) + + def shutdown(self): + """Clean up.""" + if self.ocr_thread and self.ocr_thread.isRunning(): + self.ocr_thread.wait(1000) + super().shutdown() diff --git a/plugins/global_tracker/__init__.py b/plugins/global_tracker/__init__.py new file mode 100644 index 0000000..4d0572d --- /dev/null +++ b/plugins/global_tracker/__init__.py @@ -0,0 +1,7 @@ +""" +Global Tracker Plugin +""" + +from .plugin import GlobalTrackerPlugin + +__all__ = ["GlobalTrackerPlugin"] diff --git a/plugins/global_tracker/__pycache__/__init__.cpython-312.pyc b/plugins/global_tracker/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ab5ec8e Binary files /dev/null and b/plugins/global_tracker/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/global_tracker/__pycache__/plugin.cpython-312.pyc b/plugins/global_tracker/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..c46a757 Binary files /dev/null and b/plugins/global_tracker/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/global_tracker/plugin.py b/plugins/global_tracker/plugin.py new file mode 100644 index 0000000..935d09a --- /dev/null +++ b/plugins/global_tracker/plugin.py @@ -0,0 +1,257 @@ +""" +EU-Utility - Global Tracker Plugin + +Track globals, HOFs, with notifications. +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path +from collections import deque + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QCheckBox, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin +from core.icon_manager import get_icon_manager +from PyQt6.QtGui import QPixmap + + +class GlobalTrackerPlugin(BasePlugin): + """Track globals and HOFs with stats.""" + + name = "Global Tracker" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track globals, HOFs, and ATHs" + hotkey = "ctrl+shift+g" + + def initialize(self): + """Setup global tracker.""" + self.data_file = Path("data/globals.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.globals = deque(maxlen=1000) + self.my_globals = [] + self.notifications_enabled = True + + self._load_data() + + def _load_data(self): + """Load global data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.globals.extend(data.get('globals', [])) + self.my_globals = data.get('my_globals', []) + except: + pass + + def _save_data(self): + """Save global data.""" + with open(self.data_file, 'w') as f: + json.dump({ + 'globals': list(self.globals), + 'my_globals': self.my_globals + }, f, indent=2) + + def get_ui(self): + """Create global tracker UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Get icon manager + icon_mgr = get_icon_manager() + + # Title with icon + title_layout = QHBoxLayout() + + title_icon = QLabel() + icon_pixmap = icon_mgr.get_pixmap('dollar-sign', size=20) + title_icon.setPixmap(icon_pixmap) + title_icon.setFixedSize(20, 20) + title_layout.addWidget(title_icon) + + title = QLabel("Global Tracker") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + title_layout.addWidget(title) + title_layout.addStretch() + + layout.addLayout(title_layout) + + # Stats + stats_frame = QFrame() + stats_frame.setStyleSheet(""" + QFrame { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + """) + stats_layout = QHBoxLayout(stats_frame) + + total = len(self.globals) + hofs = sum(1 for g in self.globals if g.get('value', 0) >= 1000) + + self.total_label = QLabel(f"Globals: {total}") + self.total_label.setStyleSheet("color: #4caf50; font-weight: bold;") + stats_layout.addWidget(self.total_label) + + self.hof_label = QLabel(f"HOFs: {hofs}") + self.hof_label.setStyleSheet("color: #ffc107; font-weight: bold;") + stats_layout.addWidget(self.hof_label) + + self.my_label = QLabel(f"My Globals: {len(self.my_globals)}") + self.my_label.setStyleSheet("color: #ff8c42; font-weight: bold;") + stats_layout.addWidget(self.my_label) + + stats_layout.addStretch() + layout.addWidget(stats_frame) + + # Filters + filters = QHBoxLayout() + self.show_mine_cb = QCheckBox("Show only my globals") + self.show_mine_cb.setStyleSheet("color: white;") + filters.addWidget(self.show_mine_cb) + + self.show_hof_cb = QCheckBox("HOFs only") + self.show_hof_cb.setStyleSheet("color: white;") + filters.addWidget(self.show_hof_cb) + + filters.addStretch() + layout.addLayout(filters) + + # Globals table + self.globals_table = QTableWidget() + self.globals_table.setColumnCount(5) + self.globals_table.setHorizontalHeaderLabels(["Time", "Player", "Mob/Item", "Value", "Type"]) + self.globals_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 8px; + font-weight: bold; + font-size: 11px; + } + """) + self.globals_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.globals_table) + + self._refresh_globals() + + # Test buttons + test_layout = QHBoxLayout() + + test_global = QPushButton("Test Global") + test_global.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 8px; + border: none; + border-radius: 4px; + } + """) + test_global.clicked.connect(self._test_global) + test_layout.addWidget(test_global) + + test_hof = QPushButton("Test HOF") + test_hof.setStyleSheet(""" + QPushButton { + background-color: #ffc107; + color: black; + padding: 8px; + border: none; + border-radius: 4px; + } + """) + test_hof.clicked.connect(self._test_hof) + test_layout.addWidget(test_hof) + + test_layout.addStretch() + layout.addLayout(test_layout) + + layout.addStretch() + return widget + + def _refresh_globals(self): + """Refresh globals table.""" + recent = list(self.globals)[-50:] # Last 50 + + self.globals_table.setRowCount(len(recent)) + for i, g in enumerate(reversed(recent)): + self.globals_table.setItem(i, 0, QTableWidgetItem(g.get('time', '-')[-8:])) + self.globals_table.setItem(i, 1, QTableWidgetItem(g.get('player', 'Unknown'))) + self.globals_table.setItem(i, 2, QTableWidgetItem(g.get('target', 'Unknown'))) + + value_item = QTableWidgetItem(f"{g.get('value', 0):.2f} PED") + value = g.get('value', 0) + if value >= 10000: + value_item.setForeground(Qt.GlobalColor.magenta) + elif value >= 1000: + value_item.setForeground(Qt.GlobalColor.yellow) + elif value >= 50: + value_item.setForeground(Qt.GlobalColor.green) + self.globals_table.setItem(i, 3, value_item) + + g_type = "ATH" if value >= 10000 else "HOF" if value >= 1000 else "Global" + self.globals_table.setItem(i, 4, QTableWidgetItem(g_type)) + + def _test_global(self): + """Add test global.""" + self.add_global("TestPlayer", "Argo Scout", 45.23, is_mine=True) + self._refresh_globals() + + def _test_hof(self): + """Add test HOF.""" + self.add_global("TestPlayer", "Oratan Miner", 1250.00, is_mine=True) + self._refresh_globals() + + def add_global(self, player, target, value, is_mine=False): + """Add a global.""" + entry = { + 'time': datetime.now().isoformat(), + 'player': player, + 'target': target, + 'value': value, + 'is_mine': is_mine + } + + self.globals.append(entry) + + if is_mine: + self.my_globals.append(entry) + + self._save_data() + self._refresh_globals() + + def parse_chat_message(self, message): + """Parse global from chat.""" + # Look for global patterns + import re + + # Example: "Player killed Argo Scout worth 45.23 PED!" + pattern = r'(\w+)\s+(?:killed|mined|crafted)\s+(.+?)\s+worth\s+([\d.]+)\s+PED' + match = re.search(pattern, message, re.IGNORECASE) + + if match: + player = match.group(1) + target = match.group(2) + value = float(match.group(3)) + + is_mine = player == "You" or player == "Your avatar" + self.add_global(player, target, value, is_mine) diff --git a/plugins/import_export/__init__.py b/plugins/import_export/__init__.py new file mode 100644 index 0000000..e1ed8ef --- /dev/null +++ b/plugins/import_export/__init__.py @@ -0,0 +1,3 @@ +from .plugin import ImportExportPlugin + +__all__ = ['ImportExportPlugin'] diff --git a/plugins/import_export/__pycache__/__init__.cpython-312.pyc b/plugins/import_export/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..eda85c6 Binary files /dev/null and b/plugins/import_export/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/import_export/__pycache__/plugin.cpython-312.pyc b/plugins/import_export/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..5636354 Binary files /dev/null and b/plugins/import_export/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/import_export/plugin.py b/plugins/import_export/plugin.py new file mode 100644 index 0000000..ef72401 --- /dev/null +++ b/plugins/import_export/plugin.py @@ -0,0 +1,333 @@ +# Description: Universal Import/Export tool for EU-Utility +# Author: LemonNexus +# Version: 1.0.0 + +""" +Universal Import/Export tool for EU-Utility. +Export and import all plugin data for backup and migration. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFileDialog, QProgressBar, + QListWidget, QListWidgetItem, QMessageBox, QComboBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +import json +import zipfile +import os +from datetime import datetime +from plugins.base_plugin import BasePlugin + + +class ExportImportWorker(QThread): + """Background worker for export/import operations.""" + progress = pyqtSignal(int) + status = pyqtSignal(str) + finished_signal = pyqtSignal(bool, str) + + def __init__(self, operation, data_store, path, selected_plugins=None): + super().__init__() + self.operation = operation + self.data_store = data_store + self.path = path + self.selected_plugins = selected_plugins or [] + + def run(self): + try: + if self.operation == "export": + self._do_export() + else: + self._do_import() + except Exception as e: + self.finished_signal.emit(False, str(e)) + + def _do_export(self): + """Export data to zip file.""" + self.status.emit("Gathering plugin data...") + + # Get all plugin data + all_data = {} + plugin_keys = self.data_store.get_all_keys("*") + + total = len(plugin_keys) + for i, key in enumerate(plugin_keys): + # Check if filtered + plugin_name = key.split('.')[0] if '.' in key else key + if self.selected_plugins and plugin_name not in self.selected_plugins: + continue + + data = self.data_store.load(key, None) + if data is not None: + all_data[key] = data + + progress = int((i + 1) / total * 50) + self.progress.emit(progress) + + self.status.emit("Creating export archive...") + + # Create zip file + with zipfile.ZipFile(self.path, 'w', zipfile.ZIP_DEFLATED) as zf: + # Add metadata + metadata = { + 'version': '2.0.0', + 'exported_at': datetime.now().isoformat(), + 'plugin_count': len(set(k.split('.')[0] for k in all_data.keys())), + } + zf.writestr('metadata.json', json.dumps(metadata, indent=2)) + + # Add data + zf.writestr('data.json', json.dumps(all_data, indent=2)) + + self.progress.emit(75) + + self.progress.emit(100) + self.finished_signal.emit(True, f"Exported {len(all_data)} data items") + + def _do_import(self): + """Import data from zip file.""" + self.status.emit("Reading archive...") + + with zipfile.ZipFile(self.path, 'r') as zf: + # Verify metadata + if 'metadata.json' not in zf.namelist(): + raise ValueError("Invalid export file: missing metadata") + + metadata = json.loads(zf.read('metadata.json')) + self.status.emit(f"Importing from version {metadata.get('version', 'unknown')}...") + + # Load data + data = json.loads(zf.read('data.json')) + self.progress.emit(25) + + # Import + total = len(data) + for i, (key, value) in enumerate(data.items()): + # Check if filtered + plugin_name = key.split('.')[0] if '.' in key else key + if self.selected_plugins and plugin_name not in self.selected_plugins: + continue + + self.data_store.save_raw(key, value) + progress = 25 + int((i + 1) / total * 75) + self.progress.emit(progress) + + self.finished_signal.emit(True, f"Imported {len(data)} data items") + + +class ImportExportPlugin(BasePlugin): + """Universal import/export tool for EU-Utility data.""" + + name = "Import/Export" + version = "1.0.0" + author = "LemonNexus" + description = "Backup and restore all plugin data" + icon = "archive" + + def initialize(self): + """Initialize plugin.""" + self.worker = None + self.default_export_dir = os.path.expanduser("~/Documents/EU-Utility/Backups") + os.makedirs(self.default_export_dir, exist_ok=True) + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(15) + + # Title + title = QLabel("Import / Export Data") + title.setStyleSheet("font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + # Description + desc = QLabel("Backup and restore all your EU-Utility data") + desc.setStyleSheet("color: rgba(255,255,255,150);") + layout.addWidget(desc) + + # Plugin selection + layout.addWidget(QLabel("Select plugins to export/import:")) + self.plugin_list = QListWidget() + self.plugin_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + self._populate_plugin_list() + layout.addWidget(self.plugin_list) + + # Select all/none buttons + select_layout = QHBoxLayout() + select_all_btn = QPushButton("Select All") + select_all_btn.clicked.connect(lambda: self.plugin_list.selectAll()) + select_layout.addWidget(select_all_btn) + + select_none_btn = QPushButton("Select None") + select_none_btn.clicked.connect(lambda: self.plugin_list.clearSelection()) + select_layout.addWidget(select_none_btn) + layout.addLayout(select_layout) + + # Progress + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + self.status_label = QLabel("") + self.status_label.setStyleSheet("color: #4a9eff;") + layout.addWidget(self.status_label) + + # Export/Import buttons + btn_layout = QHBoxLayout() + + export_btn = QPushButton("Export to File...") + export_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 10px; + font-weight: bold; + } + """) + export_btn.clicked.connect(self._export) + btn_layout.addWidget(export_btn) + + import_btn = QPushButton("Import from File...") + import_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: white; + padding: 10px; + font-weight: bold; + } + """) + import_btn.clicked.connect(self._import) + btn_layout.addWidget(import_btn) + + layout.addLayout(btn_layout) + + # Info + info = QLabel( + "Exports include:\n" + "- All plugin settings\n" + "- Session history\n" + "- Price alerts\n" + "- Custom configurations\n\n" + "Files are saved as .zip archives with JSON data." + ) + info.setStyleSheet("color: rgba(255,255,255,150); font-size: 11px;") + layout.addWidget(info) + + layout.addStretch() + return widget + + def _populate_plugin_list(self): + """Populate the plugin list.""" + # Get available plugins + plugins = [ + "loot_tracker", + "skill_scanner", + "price_alerts", + "session_exporter", + "mining_helper", + "mission_tracker", + "codex_tracker", + "auction_tracker", + "settings" + ] + + for plugin in plugins: + item = QListWidgetItem(plugin.replace('_', ' ').title()) + item.setData(Qt.ItemDataRole.UserRole, plugin) + self.plugin_list.addItem(item) + + def _get_selected_plugins(self): + """Get list of selected plugin names.""" + selected = [] + for item in self.plugin_list.selectedItems(): + selected.append(item.data(Qt.ItemDataRole.UserRole)) + return selected + + def _export(self): + """Export data to file.""" + selected = self._get_selected_plugins() + if not selected: + QMessageBox.warning(self.get_ui(), "No Selection", "Please select at least one plugin to export.") + return + + # Get save location + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_name = f"eu-utility-backup-{timestamp}.zip" + + path, _ = QFileDialog.getSaveFileName( + self.get_ui(), + "Export Data", + os.path.join(self.default_export_dir, default_name), + "Zip Files (*.zip)" + ) + + if not path: + return + + # Start export + self._start_worker("export", path, selected) + + def _import(self): + """Import data from file.""" + selected = self._get_selected_plugins() + if not selected: + QMessageBox.warning(self.get_ui(), "No Selection", "Please select at least one plugin to import.") + return + + # Confirm import + reply = QMessageBox.question( + self.get_ui(), + "Confirm Import", + "This will overwrite existing data for selected plugins. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply != QMessageBox.StandardButton.Yes: + return + + # Get file location + path, _ = QFileDialog.getOpenFileName( + self.get_ui(), + "Import Data", + self.default_export_dir, + "Zip Files (*.zip)" + ) + + if not path: + return + + # Start import + self._start_worker("import", path, selected) + + def _start_worker(self, operation, path, selected): + """Start background worker.""" + # Show progress + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + + # Get data store from API + data_store = self.api.services.get('data_store') if self.api else None + if not data_store: + QMessageBox.critical(self.get_ui(), "Error", "Data store not available") + return + + # Create and start worker + self.worker = ExportImportWorker(operation, data_store, path, selected) + self.worker.progress.connect(self.progress_bar.setValue) + self.worker.status.connect(self.status_label.setText) + self.worker.finished_signal.connect(self._on_worker_finished) + self.worker.start() + + def _on_worker_finished(self, success, message): + """Handle worker completion.""" + self.progress_bar.setVisible(False) + + if success: + self.status_label.setText(f"✓ {message}") + self.status_label.setStyleSheet("color: #4caf50;") + QMessageBox.information(self.get_ui(), "Success", message) + else: + self.status_label.setText(f"✗ {message}") + self.status_label.setStyleSheet("color: #f44336;") + QMessageBox.critical(self.get_ui(), "Error", message) diff --git a/plugins/inventory_manager/__init__.py b/plugins/inventory_manager/__init__.py new file mode 100644 index 0000000..64fccac --- /dev/null +++ b/plugins/inventory_manager/__init__.py @@ -0,0 +1,7 @@ +""" +Inventory Manager Plugin +""" + +from .plugin import InventoryManagerPlugin + +__all__ = ["InventoryManagerPlugin"] diff --git a/plugins/inventory_manager/__pycache__/__init__.cpython-312.pyc b/plugins/inventory_manager/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..863dcc2 Binary files /dev/null and b/plugins/inventory_manager/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/inventory_manager/__pycache__/plugin.cpython-312.pyc b/plugins/inventory_manager/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..1b80263 Binary files /dev/null and b/plugins/inventory_manager/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/inventory_manager/plugin.py b/plugins/inventory_manager/plugin.py new file mode 100644 index 0000000..656aa90 --- /dev/null +++ b/plugins/inventory_manager/plugin.py @@ -0,0 +1,219 @@ +""" +EU-Utility - Inventory Manager Plugin + +Track inventory items, value, and weight. +""" + +import json +from pathlib import Path +from collections import defaultdict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QLineEdit, QProgressBar, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class InventoryManagerPlugin(BasePlugin): + """Track and manage inventory.""" + + name = "Inventory" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track items, TT value, and weight" + hotkey = "ctrl+shift+i" + + def initialize(self): + """Setup inventory manager.""" + self.data_file = Path("data/inventory.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.items = [] + self.max_slots = 200 + self.max_weight = 355.0 + + self._load_data() + + def _load_data(self): + """Load inventory data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.items = data.get('items', []) + except: + pass + + def _save_data(self): + """Save inventory data.""" + with open(self.data_file, 'w') as f: + json.dump({'items': self.items}, f, indent=2) + + def get_ui(self): + """Create inventory UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("🎒 Inventory Manager") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Stats + stats_frame = QFrame() + stats_frame.setStyleSheet(""" + QFrame { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + """) + stats_layout = QVBoxLayout(stats_frame) + + # Slots + slots_used = len(self.items) + slots_layout = QHBoxLayout() + slots_layout.addWidget(QLabel("Slots:")) + self.slots_progress = QProgressBar() + self.slots_progress.setMaximum(self.max_slots) + self.slots_progress.setValue(slots_used) + self.slots_progress.setStyleSheet(""" + QProgressBar { + background-color: rgba(60, 70, 90, 150); + border: none; + border-radius: 3px; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: #4a9eff; + border-radius: 3px; + } + """) + slots_layout.addWidget(self.slots_progress) + self.slots_label = QLabel(f"{slots_used}/{self.max_slots}") + self.slots_label.setStyleSheet("color: white;") + slots_layout.addWidget(self.slots_label) + stats_layout.addLayout(slots_layout) + + # Weight + total_weight = sum(item.get('weight', 0) for item in self.items) + weight_layout = QHBoxLayout() + weight_layout.addWidget(QLabel("Weight:")) + self.weight_progress = QProgressBar() + self.weight_progress.setMaximum(int(self.max_weight)) + self.weight_progress.setValue(int(total_weight)) + self.weight_progress.setStyleSheet(""" + QProgressBar { + background-color: rgba(60, 70, 90, 150); + border: none; + border-radius: 3px; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: #ffc107; + border-radius: 3px; + } + """) + weight_layout.addWidget(self.weight_progress) + self.weight_label = QLabel(f"{total_weight:.1f}/{self.max_weight} kg") + self.weight_label.setStyleSheet("color: white;") + weight_layout.addWidget(self.weight_label) + stats_layout.addLayout(weight_layout) + + # PED value + total_tt = sum(item.get('tt', 0) for item in self.items) + ped_label = QLabel(f"💰 Total TT: {total_tt:.2f} PED") + ped_label.setStyleSheet("color: #ffc107; font-weight: bold;") + stats_layout.addWidget(ped_label) + + layout.addWidget(stats_frame) + + # Items table + self.items_table = QTableWidget() + self.items_table.setColumnCount(5) + self.items_table.setHorizontalHeaderLabels(["Item", "Qty", "TT", "Weight", "Container"]) + self.items_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 8px; + font-weight: bold; + font-size: 11px; + } + """) + self.items_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.items_table) + + self._refresh_items() + + # Scan button + scan_btn = QPushButton("Scan Inventory") + 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_inventory) + layout.addWidget(scan_btn) + + layout.addStretch() + return widget + + def _refresh_items(self): + """Refresh items table.""" + self.items_table.setRowCount(len(self.items)) + + for i, item in enumerate(self.items): + self.items_table.setItem(i, 0, QTableWidgetItem(item.get('name', 'Unknown'))) + self.items_table.setItem(i, 1, QTableWidgetItem(str(item.get('quantity', 1)))) + self.items_table.setItem(i, 2, QTableWidgetItem(f"{item.get('tt', 0):.2f}")) + self.items_table.setItem(i, 3, QTableWidgetItem(f"{item.get('weight', 0):.2f}")) + self.items_table.setItem(i, 4, QTableWidgetItem(item.get('container', 'Main'))) + + def _scan_inventory(self): + """Scan inventory with OCR.""" + # TODO: Implement OCR + pass + + def add_item(self, name, quantity=1, tt=0.0, weight=0.0, container='Main'): + """Add item to inventory.""" + # Check if exists + for item in self.items: + if item['name'] == name and item['container'] == container: + item['quantity'] += quantity + item['tt'] += tt + item['weight'] += weight + self._save_data() + self._refresh_items() + return + + # New item + self.items.append({ + 'name': name, + 'quantity': quantity, + 'tt': tt, + 'weight': weight, + 'container': container + }) + self._save_data() + self._refresh_items() diff --git a/plugins/log_parser_test/__init__.py b/plugins/log_parser_test/__init__.py new file mode 100644 index 0000000..eeaca4c --- /dev/null +++ b/plugins/log_parser_test/__init__.py @@ -0,0 +1,2 @@ +"""Log Parser Test Plugin.""" +from .plugin import LogParserTestPlugin diff --git a/plugins/log_parser_test/plugin.py b/plugins/log_parser_test/plugin.py new file mode 100644 index 0000000..bbfffe8 --- /dev/null +++ b/plugins/log_parser_test/plugin.py @@ -0,0 +1,384 @@ +""" +EU-Utility - Log Parser Test Plugin + +Debug and test tool for the Log Reader core service. +Monitors log parsing in real-time with detailed output. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, + QLabel, QPushButton, QComboBox, QCheckBox, + QSpinBox, QGroupBox, QSplitter, QFrame, + QTableWidget, QTableWidgetItem, QHeaderView +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QColor, QFont + +from plugins.base_plugin import BasePlugin +from core.event_bus import SkillGainEvent, LootEvent, DamageEvent, GlobalEvent + + +class LogParserTestPlugin(BasePlugin): + """Test and debug tool for log parsing functionality.""" + + name = "Log Parser Test" + version = "1.0.0" + author = "EU-Utility" + description = "Debug tool for testing log parsing and event detection" + + # Example: This plugin could depend on Game Reader Test for shared OCR utilities + # dependencies = { + # 'plugins': ['plugins.game_reader_test.plugin.GameReaderTestPlugin'] + # } + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + self.event_counts = { + 'skill_gain': 0, + 'loot': 0, + 'global': 0, + 'damage': 0, + 'damage_taken': 0, + 'heal': 0, + 'mission_complete': 0, + 'tier_increase': 0, + 'enhancer_break': 0, + 'unknown': 0 + } + self.recent_events = [] + self.max_events = 100 + self.auto_scroll = True + + def initialize(self): + """Initialize plugin and subscribe to events.""" + # Subscribe to all event types for testing + self.subscribe_typed(SkillGainEvent, self._on_skill_gain) + self.subscribe_typed(LootEvent, self._on_loot) + self.subscribe_typed(DamageEvent, self._on_damage) + self.subscribe_typed(GlobalEvent, self._on_global) + + # Also subscribe to raw log events via API + api = self._get_api() + if api: + api.subscribe('log_event', self._on_raw_log_event) + + self.log_info("Log Parser Test initialized - monitoring events...") + + def _get_api(self): + """Get PluginAPI instance.""" + try: + from core.plugin_api import get_api + return get_api() + except: + return None + + def _on_skill_gain(self, event: SkillGainEvent): + """Handle skill gain events.""" + self.event_counts['skill_gain'] += 1 + self._add_event("Skill Gain", f"{event.skill_name}: +{event.gain_amount} pts", "#4ecdc4") + + def _on_loot(self, event: LootEvent): + """Handle loot events.""" + self.event_counts['loot'] += 1 + # LootEvent has items list, extract first item for display + if event.items: + item = event.items[0] + item_name = item.get('name', 'Unknown') + quantity = item.get('quantity', 1) + value = item.get('value', 0) + value_str = f" ({value:.2f} PED)" if value else "" + self._add_event("Loot", f"{item_name} x{quantity}{value_str} from {event.mob_name}", "#ff8c42") + else: + self._add_event("Loot", f"Loot from {event.mob_name}", "#ff8c42") + + def _on_damage(self, event: DamageEvent): + """Handle damage events.""" + if event.is_outgoing: + self.event_counts['damage'] += 1 + self._add_event("Damage", f"{event.damage_amount} damage ({event.damage_type})", "#ff6b6b") + else: + self.event_counts['damage_taken'] += 1 + self._add_event("Damage Taken", f"{event.damage_amount} damage from {event.attacker_name}", "#ff4757") + + def _on_global(self, event: GlobalEvent): + """Handle global events.""" + self.event_counts['global'] += 1 + item_str = f" with {event.item_name}" if event.item_name else "" + self._add_event("Global", f"{event.player_name} got {event.achievement_type}{item_str} worth {event.value} PED!", "#ffd93d") + + def _on_raw_log_event(self, event_data): + """Handle raw log events.""" + event_type = event_data.get('event_type', 'unknown') + self.event_counts[event_type] = self.event_counts.get(event_type, 0) + 1 + + def _add_event(self, event_type: str, details: str, color: str): + """Add event to recent events list.""" + from datetime import datetime + timestamp = datetime.now().strftime("%H:%M:%S") + + self.recent_events.insert(0, { + 'time': timestamp, + 'type': event_type, + 'details': details, + 'color': color + }) + + # Trim to max + if len(self.recent_events) > self.max_events: + self.recent_events = self.recent_events[:self.max_events] + + # Update UI if available + if hasattr(self, 'events_table'): + self._update_events_table() + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(12) + + # Header + header = QLabel("📊 Log Parser Test & Debug Tool") + header.setStyleSheet("font-size: 16px; font-weight: bold; color: #ff8c42;") + layout.addWidget(header) + + # Status bar + status_frame = QFrame() + status_frame.setStyleSheet("background-color: #1a1f2e; border-radius: 6px; padding: 8px;") + status_layout = QHBoxLayout(status_frame) + + self.status_label = QLabel("Status: Monitoring...") + self.status_label.setStyleSheet("color: #4ecdc4;") + status_layout.addWidget(self.status_label) + status_layout.addStretch() + + self.log_path_label = QLabel() + self._update_log_path() + self.log_path_label.setStyleSheet("color: #666;") + status_layout.addWidget(self.log_path_label) + + layout.addWidget(status_frame) + + # Event counters + counters_group = QGroupBox("Event Counters") + counters_layout = QHBoxLayout(counters_group) + + self.counter_labels = {} + for event_type, color in [ + ('skill_gain', '#4ecdc4'), + ('loot', '#ff8c42'), + ('global', '#ffd93d'), + ('damage', '#ff6b6b'), + ('damage_taken', '#ff4757') + ]: + frame = QFrame() + frame.setStyleSheet(f"background-color: #1a1f2e; border-left: 3px solid {color}; padding: 8px;") + frame_layout = QVBoxLayout(frame) + + name_label = QLabel(event_type.replace('_', ' ').title()) + name_label.setStyleSheet("color: #888; font-size: 10px;") + frame_layout.addWidget(name_label) + + count_label = QLabel("0") + count_label.setStyleSheet(f"color: {color}; font-size: 20px; font-weight: bold;") + frame_layout.addWidget(count_label) + + self.counter_labels[event_type] = count_label + counters_layout.addWidget(frame) + + layout.addWidget(counters_group) + + # Recent events table + events_group = QGroupBox("Recent Events (last 100)") + events_layout = QVBoxLayout(events_group) + + self.events_table = QTableWidget() + self.events_table.setColumnCount(3) + self.events_table.setHorizontalHeaderLabels(["Time", "Type", "Details"]) + self.events_table.horizontalHeader().setStretchLastSection(True) + self.events_table.setStyleSheet(""" + QTableWidget { + background-color: #141f23; + border: 1px solid #333; + } + QTableWidget::item { + padding: 6px; + } + """) + events_layout.addWidget(self.events_table) + + # Auto-scroll checkbox + self.auto_scroll_cb = QCheckBox("Auto-scroll to new events") + self.auto_scroll_cb.setChecked(True) + events_layout.addWidget(self.auto_scroll_cb) + + layout.addWidget(events_group, 1) # Stretch factor 1 + + # Test controls + controls_group = QGroupBox("Test Controls") + controls_layout = QHBoxLayout(controls_group) + + # Simulate event buttons + btn_layout = QHBoxLayout() + + test_skill_btn = QPushButton("Test: Skill Gain") + test_skill_btn.clicked.connect(self._simulate_skill_gain) + btn_layout.addWidget(test_skill_btn) + + test_loot_btn = QPushButton("Test: Loot") + test_loot_btn.clicked.connect(self._simulate_loot) + btn_layout.addWidget(test_loot_btn) + + test_damage_btn = QPushButton("Test: Damage") + test_damage_btn.clicked.connect(self._simulate_damage) + btn_layout.addWidget(test_damage_btn) + + clear_btn = QPushButton("Clear Events") + clear_btn.clicked.connect(self._clear_events) + btn_layout.addWidget(clear_btn) + + controls_layout.addLayout(btn_layout) + controls_layout.addStretch() + + layout.addWidget(controls_group) + + # Raw log view + raw_group = QGroupBox("Raw Log Lines (last 50)") + raw_layout = QVBoxLayout(raw_group) + + self.raw_log_text = QTextEdit() + self.raw_log_text.setReadOnly(True) + self.raw_log_text.setStyleSheet(""" + QTextEdit { + background-color: #0d1117; + color: #c9d1d9; + font-family: Consolas, monospace; + font-size: 11px; + } + """) + raw_layout.addWidget(self.raw_log_text) + + layout.addWidget(raw_group) + + # Start update timer + self.update_timer = QTimer() + self.update_timer.timeout.connect(self._update_counters) + self.update_timer.start(1000) # Update every second + + # Subscribe to raw log lines + self.read_log(lines=50) # Pre-fill with recent log + + return widget + + def _update_log_path(self): + """Update log path display.""" + try: + from core.log_reader import LogReader + reader = LogReader() + if reader.log_path: + self.log_path_label.setText(f"Log: {reader.log_path}") + else: + self.log_path_label.setText("Log: Not found") + except Exception as e: + self.log_path_label.setText(f"Log: Error - {e}") + + def _update_counters(self): + """Update counter displays.""" + for event_type, label in self.counter_labels.items(): + count = self.event_counts.get(event_type, 0) + label.setText(str(count)) + + def _update_events_table(self): + """Update events table with recent events.""" + self.events_table.setRowCount(len(self.recent_events)) + + for i, event in enumerate(self.recent_events): + # Time + time_item = QTableWidgetItem(event['time']) + time_item.setForeground(QColor("#888")) + self.events_table.setItem(i, 0, time_item) + + # Type + type_item = QTableWidgetItem(event['type']) + type_item.setForeground(QColor(event['color'])) + type_item.setFont(QFont("Segoe UI", weight=QFont.Weight.Bold)) + self.events_table.setItem(i, 1, type_item) + + # Details + details_item = QTableWidgetItem(event['details']) + details_item.setForeground(QColor("#c9d1d9")) + self.events_table.setItem(i, 2, details_item) + + # Auto-scroll + if self.auto_scroll_cb.isChecked() and self.recent_events: + self.events_table.scrollToTop() + + def _simulate_skill_gain(self): + """Simulate a skill gain event for testing.""" + from datetime import datetime + event = SkillGainEvent( + timestamp=datetime.now(), + skill_name="Rifle", + gain_amount=0.1234, + skill_value=1234.56 + ) + self._on_skill_gain(event) + self.log_info("Simulated skill gain event") + + def _simulate_loot(self): + """Simulate a loot event for testing.""" + from datetime import datetime + event = LootEvent( + timestamp=datetime.now(), + mob_name="Atrox", + items=[{'name': 'Shrapnel', 'quantity': 100, 'value': 1.0}], + total_tt_value=1.0, + position=None + ) + self._on_loot(event) + self.log_info("Simulated loot event") + + def _simulate_damage(self): + """Simulate a damage event for testing.""" + from datetime import datetime + event = DamageEvent( + timestamp=datetime.now(), + damage_amount=45, + damage_type="Impact", + is_critical=True, + target_name="Atrox", + attacker_name="Player", + is_outgoing=True + ) + self._on_damage(event) + self.log_info("Simulated damage event") + + def _clear_events(self): + """Clear all events.""" + self.recent_events.clear() + for key in self.event_counts: + self.event_counts[key] = 0 + self._update_events_table() + self._update_counters() + self.log_info("Cleared all events") + + def read_log(self, lines=50): + """Read recent log lines.""" + try: + # Call parent class method (BasePlugin.read_log) + log_lines = super().read_log(lines=lines) + if log_lines: + self.raw_log_text.setPlainText('\n'.join(log_lines)) + except Exception as e: + self.raw_log_text.setPlainText(f"Error reading log: {e}") + + def on_show(self): + """Called when plugin is shown.""" + self._update_events_table() + self._update_counters() + + def shutdown(self): + """Clean up.""" + if hasattr(self, 'update_timer'): + self.update_timer.stop() + super().shutdown() diff --git a/plugins/loot_tracker/__init__.py b/plugins/loot_tracker/__init__.py new file mode 100644 index 0000000..b7024fd --- /dev/null +++ b/plugins/loot_tracker/__init__.py @@ -0,0 +1,7 @@ +""" +Loot Tracker Plugin +""" + +from .plugin import LootTrackerPlugin + +__all__ = ["LootTrackerPlugin"] diff --git a/plugins/loot_tracker/__pycache__/__init__.cpython-312.pyc b/plugins/loot_tracker/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..354baac Binary files /dev/null and b/plugins/loot_tracker/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/loot_tracker/__pycache__/plugin.cpython-312.pyc b/plugins/loot_tracker/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..4af5b68 Binary files /dev/null and b/plugins/loot_tracker/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/loot_tracker/plugin.py b/plugins/loot_tracker/plugin.py new file mode 100644 index 0000000..695350b --- /dev/null +++ b/plugins/loot_tracker/plugin.py @@ -0,0 +1,224 @@ +""" +EU-Utility - Loot Tracker Plugin + +Track and analyze hunting loot with statistics and ROI. +""" + +import re +from datetime import datetime +from collections import defaultdict +from pathlib import Path +import json + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QComboBox, QLineEdit, QTabWidget, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class LootTrackerPlugin(BasePlugin): + """Track hunting loot and calculate ROI.""" + + name = "Loot Tracker" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track hunting loot with stats and ROI analysis" + hotkey = "ctrl+shift+l" # L for Loot + + def initialize(self): + """Setup loot tracker.""" + self.data_file = Path("data/loot_tracker.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.sessions = [] + self.current_session = { + 'start_time': None, + 'kills': 0, + 'loot_items': [], + 'total_tt': 0.0, + 'ammo_cost': 0.0, + 'weapon_decay': 0.0, + } + + self._load_data() + + def _load_data(self): + """Load historical data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + self.sessions = json.load(f) + except: + self.sessions = [] + + def _save_data(self): + """Save data to file.""" + with open(self.data_file, 'w') as f: + json.dump(self.sessions, f, indent=2) + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("Loot Tracker") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Stats summary + stats_frame = QFrame() + stats_frame.setStyleSheet(""" + QFrame { + background-color: rgba(0, 0, 0, 50); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 20); + } + """) + stats_layout = QHBoxLayout(stats_frame) + + self.kills_label = QLabel("Kills: 0") + self.kills_label.setStyleSheet("color: #4caf50; font-size: 14px; font-weight: bold;") + stats_layout.addWidget(self.kills_label) + + self.tt_label = QLabel("TT: 0.00 PED") + self.tt_label.setStyleSheet("color: #ffc107; font-size: 14px; font-weight: bold;") + stats_layout.addWidget(self.tt_label) + + self.roi_label = QLabel("ROI: 0%") + self.roi_label.setStyleSheet("color: #4a9eff; font-size: 14px; font-weight: bold;") + stats_layout.addWidget(self.roi_label) + + layout.addWidget(stats_frame) + + # Session controls + controls = QHBoxLayout() + + self.start_btn = QPushButton("▶ Start Session") + self.start_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 8px; + font-weight: bold; + } + QPushButton:hover { + background-color: #5cbf60; + } + """) + self.start_btn.clicked.connect(self._start_session) + controls.addWidget(self.start_btn) + + self.stop_btn = QPushButton("⏹ Stop Session") + self.stop_btn.setStyleSheet(""" + QPushButton { + background-color: #f44336; + color: white; + padding: 10px 20px; + border: none; + border-radius: 8px; + font-weight: bold; + } + QPushButton:hover { + background-color: #f55a4e; + } + """) + self.stop_btn.clicked.connect(self._stop_session) + self.stop_btn.setEnabled(False) + controls.addWidget(self.stop_btn) + + layout.addLayout(controls) + + # Loot table + self.loot_table = QTableWidget() + self.loot_table.setColumnCount(4) + self.loot_table.setHorizontalHeaderLabels(["Item", "Qty", "TT Value", "Time"]) + self.loot_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 30, 30, 100); + color: white; + border: none; + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(74, 158, 255, 100); + color: white; + padding: 6px; + } + """) + self.loot_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.loot_table) + + layout.addStretch() + return widget + + def _start_session(self): + """Start new hunting session.""" + self.current_session = { + 'start_time': datetime.now().isoformat(), + 'kills': 0, + 'loot_items': [], + 'total_tt': 0.0, + 'ammo_cost': 0.0, + 'weapon_decay': 0.0, + } + self.start_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + + def _stop_session(self): + """Stop current session and save.""" + self.current_session['end_time'] = datetime.now().isoformat() + self.sessions.append(self.current_session) + self._save_data() + + self.start_btn.setEnabled(True) + self.stop_btn.setEnabled(False) + self._update_stats() + + def _update_stats(self): + """Update statistics display.""" + kills = self.current_session.get('kills', 0) + tt = self.current_session.get('total_tt', 0.0) + + self.kills_label.setText(f"Kills: {kills}") + self.tt_label.setText(f"TT: {tt:.2f} PED") + + def parse_chat_message(self, message): + """Parse loot from chat.""" + # Look for loot patterns + # Example: "You received Animal Hide (0.03 PED)" + loot_pattern = r'You received (.+?) \(([\d.]+) PED\)' + match = re.search(loot_pattern, message) + + if match: + item_name = match.group(1) + tt_value = float(match.group(2)) + + self.current_session['loot_items'].append({ + 'item': item_name, + 'tt': tt_value, + 'time': datetime.now().isoformat() + }) + self.current_session['total_tt'] += tt_value + self._update_stats() + + # Check for new kill + # Multiple items in quick succession = one kill + # Longer gap = new kill + self.current_session['kills'] += 1 + + def on_hotkey(self): + """Toggle session on hotkey.""" + if self.start_btn.isEnabled(): + self._start_session() + else: + self._stop_session() diff --git a/plugins/mining_helper/__init__.py b/plugins/mining_helper/__init__.py new file mode 100644 index 0000000..ac46eea --- /dev/null +++ b/plugins/mining_helper/__init__.py @@ -0,0 +1,7 @@ +""" +Mining Helper Plugin +""" + +from .plugin import MiningHelperPlugin + +__all__ = ["MiningHelperPlugin"] diff --git a/plugins/mining_helper/__pycache__/__init__.cpython-312.pyc b/plugins/mining_helper/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e4283f7 Binary files /dev/null and b/plugins/mining_helper/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/mining_helper/__pycache__/plugin.cpython-312.pyc b/plugins/mining_helper/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..59715c9 Binary files /dev/null and b/plugins/mining_helper/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/mining_helper/plugin.py b/plugins/mining_helper/plugin.py new file mode 100644 index 0000000..13004e8 --- /dev/null +++ b/plugins/mining_helper/plugin.py @@ -0,0 +1,273 @@ +""" +EU-Utility - Mining Helper Plugin + +Track mining finds, claims, and locations. +""" + +import json +from datetime import datetime +from pathlib import Path +from collections import defaultdict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QComboBox, QTextEdit +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class MiningHelperPlugin(BasePlugin): + """Track mining activities and claims.""" + + name = "Mining Helper" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track mining finds, claims, and hotspot locations" + hotkey = "ctrl+shift+n" # N for miNiNg + + # Resource types + RESOURCES = [ + "Alicenies Liquid", + "Ares Powder", + "Blausariam", + "Caldorite", + "Cobalt", + "Copper", + "Dianthus", + "Erdorium", + "Frigulite", + "Ganganite", + "Himi", + "Ignisium", + "Iron", + "Kaz Ingot", + "Lysterium", + "Maganite", + "Niksarium", + "Oil", + "Platinum", + "Redulite", + "Rubio", + "Sopur", + "Titan", + "Typonolic Steam", + "Uranium", + "Veldspar", + "Xantium", + "Zinc", + ] + + def initialize(self): + """Setup mining helper.""" + self.data_file = Path("data/mining_helper.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.claims = [] + self.current_run = { + 'start_time': None, + 'drops': 0, + 'finds': [], + 'total_tt': 0.0, + } + + self._load_data() + + def _load_data(self): + """Load historical data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.claims = data.get('claims', []) + except: + self.claims = [] + + def _save_data(self): + """Save data.""" + with open(self.data_file, 'w') as f: + json.dump({'claims': self.claims}, f, indent=2) + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("⛏️ Mining Helper") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Stats + stats = QHBoxLayout() + + self.drops_label = QLabel("Drops: 0") + self.drops_label.setStyleSheet("color: #9c27b0; font-size: 14px; font-weight: bold;") + stats.addWidget(self.drops_label) + + self.finds_label = QLabel("Finds: 0") + self.finds_label.setStyleSheet("color: #4caf50; font-size: 14px; font-weight: bold;") + stats.addWidget(self.finds_label) + + self.hit_rate_label = QLabel("Hit Rate: 0%") + self.hit_rate_label.setStyleSheet("color: #ffc107; font-size: 14px; font-weight: bold;") + stats.addWidget(self.hit_rate_label) + + layout.addLayout(stats) + + # Quick add claim + add_frame = QWidget() + add_frame.setStyleSheet(""" + QWidget { + background-color: rgba(0, 0, 0, 50); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 20); + } + """) + add_layout = QHBoxLayout(add_frame) + add_layout.setContentsMargins(10, 10, 10, 10) + + self.resource_combo = QComboBox() + self.resource_combo.addItems(self.RESOURCES) + self.resource_combo.setStyleSheet(""" + QComboBox { + background-color: rgba(255, 255, 255, 15); + color: white; + border: none; + padding: 5px; + border-radius: 4px; + } + """) + add_layout.addWidget(self.resource_combo) + + self.size_combo = QComboBox() + self.size_combo.addItems(["Tiny", "Small", "Medium", "Large", "Huge", "Massive"]) + self.size_combo.setStyleSheet(self.resource_combo.styleSheet()) + add_layout.addWidget(self.size_combo) + + add_btn = QPushButton("+ Add Claim") + add_btn.setStyleSheet(""" + QPushButton { + background-color: #9c27b0; + color: white; + padding: 8px 15px; + border: none; + border-radius: 6px; + font-weight: bold; + } + QPushButton:hover { + background-color: #ab47bc; + } + """) + add_btn.clicked.connect(self._add_claim) + add_layout.addWidget(add_btn) + + layout.addWidget(add_frame) + + # Claims table + self.claims_table = QTableWidget() + self.claims_table.setColumnCount(4) + self.claims_table.setHorizontalHeaderLabels(["Resource", "Size", "TT", "Time"]) + self.claims_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 30, 30, 100); + color: white; + border: none; + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(156, 39, 176, 100); + color: white; + padding: 6px; + } + """) + self.claims_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.claims_table) + + layout.addStretch() + return widget + + def _add_claim(self): + """Add a claim manually.""" + resource = self.resource_combo.currentText() + size = self.size_combo.currentText() + + claim = { + 'resource': resource, + 'size': size, + 'tt_value': self._estimate_tt(size), + 'time': datetime.now().isoformat(), + 'location': None, # Could get from game + } + + self.claims.append(claim) + self._save_data() + self._update_table() + self._update_stats() + + def _estimate_tt(self, size): + """Estimate TT value based on claim size.""" + estimates = { + 'Tiny': 0.05, + 'Small': 0.25, + 'Medium': 1.00, + 'Large': 5.00, + 'Huge': 25.00, + 'Massive': 100.00, + } + return estimates.get(size, 0.05) + + def _update_table(self): + """Update claims table.""" + recent = self.claims[-20:] # Show last 20 + self.claims_table.setRowCount(len(recent)) + + for i, claim in enumerate(recent): + self.claims_table.setItem(i, 0, QTableWidgetItem(claim['resource'])) + self.claims_table.setItem(i, 1, QTableWidgetItem(claim['size'])) + self.claims_table.setItem(i, 2, QTableWidgetItem(f"{claim['tt_value']:.2f}")) + time_str = claim['time'][11:16] if claim['time'] else '-' + self.claims_table.setItem(i, 3, QTableWidgetItem(time_str)) + + def _update_stats(self): + """Update statistics.""" + drops = len(self.claims) + 10 # Estimate + finds = len(self.claims) + hit_rate = (finds / drops * 100) if drops > 0 else 0 + + self.drops_label.setText(f"Drops: ~{drops}") + self.finds_label.setText(f"Finds: {finds}") + self.hit_rate_label.setText(f"Hit Rate: {hit_rate:.1f}%") + + def parse_chat_message(self, message): + """Parse mining claims from chat.""" + # Look for claim patterns + # Example: "You found a Tiny Lysterium claim" + for resource in self.RESOURCES: + if resource in message and "claim" in message.lower(): + # Extract size + sizes = ["Tiny", "Small", "Medium", "Large", "Huge", "Massive"] + size = "Unknown" + for s in sizes: + if s in message: + size = s + break + + claim = { + 'resource': resource, + 'size': size, + 'tt_value': self._estimate_tt(size), + 'time': datetime.now().isoformat(), + 'location': None, + } + + self.claims.append(claim) + self._save_data() + self._update_table() + self._update_stats() + break diff --git a/plugins/mission_tracker/__init__.py b/plugins/mission_tracker/__init__.py new file mode 100644 index 0000000..de1d978 --- /dev/null +++ b/plugins/mission_tracker/__init__.py @@ -0,0 +1,7 @@ +""" +Mission Tracker Plugin +""" + +from .plugin import MissionTrackerPlugin + +__all__ = ["MissionTrackerPlugin"] diff --git a/plugins/mission_tracker/__pycache__/__init__.cpython-312.pyc b/plugins/mission_tracker/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b938697 Binary files /dev/null and b/plugins/mission_tracker/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/mission_tracker/__pycache__/plugin.cpython-312.pyc b/plugins/mission_tracker/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..e97a201 Binary files /dev/null and b/plugins/mission_tracker/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/mission_tracker/plugin.py b/plugins/mission_tracker/plugin.py new file mode 100644 index 0000000..bf2c878 --- /dev/null +++ b/plugins/mission_tracker/plugin.py @@ -0,0 +1,315 @@ +""" +EU-Utility - Mission Tracker Plugin + +Track active missions and progress. +""" + +import json +from datetime import datetime +from pathlib import Path + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QProgressBar, QFrame, QScrollArea +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin +from core.icon_manager import get_icon_manager + + +class MissionTrackerPlugin(BasePlugin): + """Track active missions and daily challenges.""" + + name = "Mission Tracker" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track missions, challenges, and objectives" + hotkey = "ctrl+shift+m" + + def initialize(self): + """Setup mission tracker.""" + self.data_file = Path("data/missions.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.missions = [] + self.daily_challenges = [] + + self._load_data() + + def _load_data(self): + """Load mission data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.missions = data.get('missions', []) + self.daily_challenges = data.get('daily', []) + except: + pass + + def _save_data(self): + """Save mission data.""" + with open(self.data_file, 'w') as f: + json.dump({ + 'missions': self.missions, + 'daily': self.daily_challenges + }, f, indent=2) + + def get_ui(self): + """Create mission tracker UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("📜 Mission Tracker") + title.setStyleSheet(""" + color: white; + font-size: 16px; + font-weight: bold; + """) + layout.addWidget(title) + + # Active missions section + active_label = QLabel("Active Missions") + active_label.setStyleSheet("color: rgba(255,255,255,200); font-size: 12px;") + layout.addWidget(active_label) + + # Mission cards + self.missions_container = QWidget() + self.missions_layout = QVBoxLayout(self.missions_container) + self.missions_layout.setSpacing(10) + self.missions_layout.setContentsMargins(0, 0, 0, 0) + + self._refresh_missions() + layout.addWidget(self.missions_container) + + # Daily challenges + daily_label = QLabel("Daily Challenges") + daily_label.setStyleSheet("color: rgba(255,255,255,200); font-size: 12px; margin-top: 10px;") + layout.addWidget(daily_label) + + self.daily_container = QWidget() + self.daily_layout = QVBoxLayout(self.daily_container) + self.daily_layout.setSpacing(8) + self.daily_layout.setContentsMargins(0, 0, 0, 0) + + self._refresh_daily() + layout.addWidget(self.daily_container) + + # Add mission button + add_btn = QPushButton("+ Add Mission") + add_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255, 140, 66, 200); + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: rgba(255, 160, 80, 230); + } + """) + add_btn.clicked.connect(self._add_mission) + layout.addWidget(add_btn) + + layout.addStretch() + return widget + + def _create_mission_card(self, mission): + """Create a mission card widget.""" + card = QFrame() + card.setStyleSheet(""" + QFrame { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + """) + layout = QVBoxLayout(card) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(8) + + # Header + header = QHBoxLayout() + + name = QLabel(mission.get('name', 'Unknown Mission')) + name.setStyleSheet("color: #ff8c42; font-weight: bold; font-size: 12px;") + header.addWidget(name) + + header.addStretch() + + # Complete button + complete_btn = QPushButton("✓") + complete_btn.setFixedSize(24, 24) + complete_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(76, 175, 80, 150); + color: white; + border: none; + border-radius: 3px; + font-weight: bold; + } + QPushButton:hover { + background-color: rgba(76, 175, 80, 200); + } + """) + complete_btn.clicked.connect(lambda: self._complete_mission(mission)) + header.addWidget(complete_btn) + + layout.addLayout(header) + + # Progress + current = mission.get('current', 0) + total = mission.get('total', 1) + pct = min(100, int(current / total * 100)) + + progress_layout = QHBoxLayout() + + progress = QProgressBar() + progress.setValue(pct) + progress.setTextVisible(False) + progress.setFixedHeight(6) + progress.setStyleSheet(""" + QProgressBar { + background-color: rgba(60, 70, 90, 150); + border: none; + border-radius: 3px; + } + QProgressBar::chunk { + background-color: #ff8c42; + border-radius: 3px; + } + """) + progress_layout.addWidget(progress, 1) + + progress_text = QLabel(f"{current}/{total}") + progress_text.setStyleSheet("color: rgba(255,255,255,150); font-size: 10px;") + progress_layout.addWidget(progress_text) + + layout.addLayout(progress_layout) + + return card + + def _create_challenge_card(self, challenge): + """Create a daily challenge card.""" + card = QFrame() + card.setStyleSheet(""" + QFrame { + background-color: rgba(25, 30, 40, 180); + border: 1px solid rgba(80, 90, 110, 60); + border-radius: 4px; + } + """) + layout = QHBoxLayout(card) + layout.setContentsMargins(10, 8, 10, 8) + layout.setSpacing(10) + + # Icon based on type + icon_mgr = get_icon_manager() + icon_label = QLabel() + icon_pixmap = icon_mgr.get_pixmap('sword', size=16) + icon_label.setPixmap(icon_pixmap) + icon_label.setFixedSize(16, 16) + layout.addWidget(icon_label) + + # Name + name = QLabel(challenge.get('name', 'Challenge')) + name.setStyleSheet("color: white; font-size: 11px;") + layout.addWidget(name) + + layout.addStretch() + + # Progress + current = challenge.get('current', 0) + total = challenge.get('total', 1) + pct = min(100, int(current / total * 100)) + + progress = QProgressBar() + progress.setValue(pct) + progress.setTextVisible(False) + progress.setFixedSize(60, 4) + progress.setStyleSheet(""" + QProgressBar { + background-color: rgba(60, 70, 90, 150); + border: none; + border-radius: 2px; + } + QProgressBar::chunk { + background-color: #ffc107; + border-radius: 2px; + } + """) + layout.addWidget(progress) + + text = QLabel(f"{current}/{total}") + text.setStyleSheet("color: rgba(255,255,255,120); font-size: 10px;") + layout.addWidget(text) + + return card + + def _refresh_missions(self): + """Refresh mission display.""" + # Clear existing + while self.missions_layout.count(): + item = self.missions_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + # Add missions + for mission in self.missions: + card = self._create_mission_card(mission) + self.missions_layout.addWidget(card) + + def _refresh_daily(self): + """Refresh daily challenges.""" + while self.daily_layout.count(): + item = self.daily_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + for challenge in self.daily_challenges: + card = self._create_challenge_card(challenge) + self.daily_layout.addWidget(card) + + def _add_mission(self): + """Add a new mission.""" + # Add sample mission + self.missions.append({ + 'name': 'Oratan Payback Mission III', + 'current': 0, + 'total': 100, + 'type': 'kill', + 'target': 'Oratan Prospector Bandits', + 'added': datetime.now().isoformat() + }) + self._save_data() + self._refresh_missions() + + def _complete_mission(self, mission): + """Mark mission as complete.""" + if mission in self.missions: + self.missions.remove(mission) + self._save_data() + self._refresh_missions() + + def parse_chat_message(self, message): + """Parse mission progress from chat.""" + # Look for mission progress + # Example: "Mission updated: 12/100 Oratan Prospector Bandits" + for mission in self.missions: + target = mission.get('target', '') + if target and target in message: + # Extract progress + import re + match = re.search(r'(\d+)/(\d+)', message) + if match: + mission['current'] = int(match.group(1)) + mission['total'] = int(match.group(2)) + self._save_data() + self._refresh_missions() diff --git a/plugins/nexus_search/__init__.py b/plugins/nexus_search/__init__.py new file mode 100644 index 0000000..fb6a458 --- /dev/null +++ b/plugins/nexus_search/__init__.py @@ -0,0 +1,7 @@ +""" +Nexus Search Plugin for EU-Utility +""" + +from .plugin import NexusSearchPlugin + +__all__ = ["NexusSearchPlugin"] diff --git a/plugins/nexus_search/__pycache__/__init__.cpython-312.pyc b/plugins/nexus_search/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c2c6fda Binary files /dev/null and b/plugins/nexus_search/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/nexus_search/__pycache__/plugin.cpython-312.pyc b/plugins/nexus_search/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..804f0c5 Binary files /dev/null and b/plugins/nexus_search/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/nexus_search/plugin.py b/plugins/nexus_search/plugin.py new file mode 100644 index 0000000..6dea9f3 --- /dev/null +++ b/plugins/nexus_search/plugin.py @@ -0,0 +1,442 @@ +""" +EU-Utility - Nexus Search Plugin + +Built-in plugin for searching EntropiaNexus via API. +Uses official Nexus API endpoints. +""" + +import json +import webbrowser +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLineEdit, QPushButton, QLabel, QComboBox, + QListWidget, QListWidgetItem, QTabWidget, + QTableWidget, QTableWidgetItem, QHeaderView +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +from plugins.base_plugin import BasePlugin + + +class NexusAPIClient: + """Client for EntropiaNexus API.""" + + BASE_URL = "https://www.entropianexus.com" + + @classmethod + def fetch_exchange_items(cls, search_query=None, http_get_func=None): + """Fetch exchange items from Nexus API.""" + try: + url = f"{cls.BASE_URL}/api/market/exchange" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=60, # 1 minute cache for market data + headers={'Accept': 'application/json', 'Accept-Encoding': 'gzip'} + ) + data = response.get('json') if response else None + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json', 'Accept-Encoding': 'gzip'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode('utf-8')) + + # Filter by search query if provided + if search_query and data: + search_lower = search_query.lower() + filtered = [] + for category in data: + if 'items' in category: + for item in category['items']: + if search_lower in item.get('name', '').lower(): + filtered.append(item) + return filtered + + return data + + except Exception as e: + print(f"API Error: {e}") + return None + + @classmethod + def fetch_item_prices(cls, item_ids, http_get_func=None): + """Fetch latest prices for items.""" + try: + if not item_ids: + return {} + + ids_str = ','.join(str(id) for id in item_ids[:100]) # Max 100 + url = f"{cls.BASE_URL}/api/market/prices/latest?items={ids_str}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=60, # 1 minute cache + headers={'Accept': 'application/json'} + ) + return response.get('json') if response else {} + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode('utf-8')) + + except Exception as e: + print(f"Price API Error: {e}") + return {} + + @classmethod + def search_users(cls, query, http_get_func=None): + """Search for verified users.""" + try: + params = {'q': query, 'limit': 10} + query_string = '&'.join(f"{k}={v}" for k, v in params.items()) + url = f"{cls.BASE_URL}/api/users/search?{query_string}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=300, # 5 minute cache for user search + headers={'Accept': 'application/json'} + ) + return response.get('json') if response else None + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json'} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode('utf-8')) + + except Exception as e: + print(f"User Search Error: {e}") + return None + + +class NexusSearchThread(QThread): + """Background thread for API searches.""" + results_ready = pyqtSignal(list, str) # results, search_type + error_occurred = pyqtSignal(str) + + def __init__(self, query, search_type, http_get_func=None): + super().__init__() + self.query = query + self.search_type = search_type + self.http_get_func = http_get_func + + def run(self): + """Perform API search.""" + try: + results = [] + + if self.search_type == "Items": + # Search exchange items + data = NexusAPIClient.fetch_exchange_items(self.query, http_get_func=self.http_get_func) + if data: + if isinstance(data, list) and len(data) > 0 and 'name' in data[0]: + # Already filtered items + results = data[:20] # Limit to 20 + else: + # Full category structure + for category in data: + if 'items' in category: + for item in category['items']: + if self.query.lower() in item.get('name', '').lower(): + results.append(item) + if len(results) >= 20: + break + if len(results) >= 20: + break + + elif self.search_type == "Users": + # Search users + data = NexusAPIClient.search_users(self.query, http_get_func=self.http_get_func) + if data: + results = data[:10] + + self.results_ready.emit(results, self.search_type) + + except Exception as e: + self.error_occurred.emit(str(e)) + + +class NexusSearchPlugin(BasePlugin): + """Search EntropiaNexus via API.""" + + name = "EntropiaNexus" + version = "1.1.0" + author = "ImpulsiveFPS" + description = "Search items, users, and market data via Nexus API" + hotkey = "ctrl+shift+n" + + def initialize(self): + """Setup the plugin.""" + self.base_url = "https://www.entropianexus.com" + self.search_thread = None + self.current_results = [] + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + + # Title + title = QLabel("EntropiaNexus") + title.setStyleSheet("color: #4a9eff; font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + # Search type + type_layout = QHBoxLayout() + type_layout.addWidget(QLabel("Search:")) + + self.search_type = QComboBox() + self.search_type.addItems([ + "Items", + "Users", + ]) + self.search_type.setStyleSheet(""" + QComboBox { + background-color: #444; + color: white; + padding: 5px; + border-radius: 4px; + min-width: 100px; + } + """) + type_layout.addWidget(self.search_type) + + # Search input + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Enter search term...") + self.search_input.setStyleSheet(""" + QLineEdit { + background-color: #333; + color: white; + padding: 8px; + border: 2px solid #555; + border-radius: 4px; + font-size: 13px; + } + QLineEdit:focus { + border-color: #4a9eff; + } + """) + self.search_input.returnPressed.connect(self._do_search) + type_layout.addWidget(self.search_input, 1) + + # Search button + search_btn = QPushButton("🔍") + search_btn.setFixedWidth(40) + search_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + } + QPushButton:hover { + background-color: #5aafff; + } + """) + search_btn.clicked.connect(self._do_search) + type_layout.addWidget(search_btn) + + layout.addLayout(type_layout) + + # Status + self.status_label = QLabel("Ready") + self.status_label.setStyleSheet("color: #666; font-size: 11px;") + layout.addWidget(self.status_label) + + # Results table + self.results_table = QTableWidget() + self.results_table.setColumnCount(3) + self.results_table.setHorizontalHeaderLabels(["Name", "Type", "Price"]) + self.results_table.horizontalHeader().setStretchLastSection(True) + self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.results_table.setStyleSheet(""" + QTableWidget { + background-color: #2a2a2a; + color: white; + border: 1px solid #444; + border-radius: 4px; + gridline-color: #444; + } + QTableWidget::item { + padding: 8px; + border-bottom: 1px solid #333; + } + QTableWidget::item:selected { + background-color: #4a9eff; + } + QHeaderView::section { + background-color: #333; + color: #aaa; + padding: 8px; + border: none; + font-weight: bold; + } + """) + self.results_table.cellClicked.connect(self._on_item_clicked) + self.results_table.setMaximumHeight(300) + layout.addWidget(self.results_table) + + # Action buttons + btn_layout = QHBoxLayout() + + open_btn = QPushButton("Open on Nexus") + open_btn.setStyleSheet(""" + QPushButton { + background-color: #333; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + } + QPushButton:hover { + background-color: #444; + } + """) + open_btn.clicked.connect(self._open_selected) + btn_layout.addWidget(open_btn) + + btn_layout.addStretch() + + # Quick links + links_label = QLabel("Quick:") + links_label.setStyleSheet("color: #666;") + btn_layout.addWidget(links_label) + + for name, url in [ + ("Market", "/market/exchange"), + ("Items", "/items"), + ("Mobs", "/mobs"), + ]: + btn = QPushButton(name) + btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #4a9eff; + border: none; + padding: 5px; + } + QPushButton:hover { + color: #5aafff; + text-decoration: underline; + } + """) + btn.clicked.connect(lambda checked, u=self.base_url + url: webbrowser.open(u)) + btn_layout.addWidget(btn) + + layout.addLayout(btn_layout) + layout.addStretch() + + return widget + + def _do_search(self): + """Perform API search.""" + query = self.search_input.text().strip() + if not query or len(query) < 2: + self.status_label.setText("Enter at least 2 characters") + return + + search_type = self.search_type.currentText() + + # Clear previous results + self.results_table.setRowCount(0) + self.current_results = [] + self.status_label.setText("Searching...") + + # Start search thread with http_get function + self.search_thread = NexusSearchThread(query, search_type, http_get_func=self.http_get) + self.search_thread.results_ready.connect(self._on_results) + self.search_thread.error_occurred.connect(self._on_error) + self.search_thread.start() + + def _on_results(self, results, search_type): + """Handle search results.""" + self.current_results = results + + if not results: + self.status_label.setText("No results found") + return + + # Populate table + self.results_table.setRowCount(len(results)) + + for row, item in enumerate(results): + if search_type == "Items": + name = item.get('name', 'Unknown') + item_type = item.get('type', 'Item') + + # Price info + buy_price = item.get('buy', []) + sell_price = item.get('sell', []) + + if buy_price: + price_text = f"Buy: {buy_price[0].get('price', 'N/A')}" + elif sell_price: + price_text = f"Sell: {sell_price[0].get('price', 'N/A')}" + else: + price_text = "No orders" + + self.results_table.setItem(row, 0, QTableWidgetItem(name)) + self.results_table.setItem(row, 1, QTableWidgetItem(item_type)) + self.results_table.setItem(row, 2, QTableWidgetItem(price_text)) + + elif search_type == "Users": + name = item.get('name', 'Unknown') + eu_name = item.get('euName', '') + + self.results_table.setItem(row, 0, QTableWidgetItem(name)) + self.results_table.setItem(row, 1, QTableWidgetItem("User")) + self.results_table.setItem(row, 2, QTableWidgetItem(eu_name or '')) + + self.status_label.setText(f"Found {len(results)} results") + + def _on_error(self, error): + """Handle search error.""" + self.status_label.setText(f"Error: {error}") + + def _on_item_clicked(self, row, column): + """Handle item click.""" + if row < len(self.current_results): + item = self.current_results[row] + search_type = self.search_type.currentText() + + if search_type == "Items": + item_id = item.get('id') + if item_id: + url = f"{self.base_url}/items/{item_id}" + webbrowser.open(url) + + elif search_type == "Users": + user_id = item.get('id') + if user_id: + url = f"{self.base_url}/users/{user_id}" + webbrowser.open(url) + + def _open_selected(self): + """Open selected item in browser.""" + selected = self.results_table.selectedItems() + if selected: + row = selected[0].row() + self._on_item_clicked(row, 0) + + def on_hotkey(self): + """Focus search when hotkey pressed.""" + if hasattr(self, 'search_input'): + self.search_input.setFocus() + self.search_input.selectAll() diff --git a/plugins/plugin_store_ui/__init__.py b/plugins/plugin_store_ui/__init__.py new file mode 100644 index 0000000..e9d630b --- /dev/null +++ b/plugins/plugin_store_ui/__init__.py @@ -0,0 +1,7 @@ +""" +Plugin Store UI Plugin +""" + +from .plugin import PluginStoreUIPlugin + +__all__ = ["PluginStoreUIPlugin"] diff --git a/plugins/plugin_store_ui/__pycache__/__init__.cpython-312.pyc b/plugins/plugin_store_ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2f812d3 Binary files /dev/null and b/plugins/plugin_store_ui/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/plugin_store_ui/__pycache__/plugin.cpython-312.pyc b/plugins/plugin_store_ui/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..7f0c6d4 Binary files /dev/null and b/plugins/plugin_store_ui/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/plugin_store_ui/plugin.py b/plugins/plugin_store_ui/plugin.py new file mode 100644 index 0000000..6a92ae6 --- /dev/null +++ b/plugins/plugin_store_ui/plugin.py @@ -0,0 +1,273 @@ +""" +EU-Utility - Plugin Store UI Plugin + +Browse and install community plugins. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QLineEdit, QListWidget, QListWidgetItem, + QProgressBar, QFrame, QTextEdit +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +from plugins.base_plugin import BasePlugin + + +class PluginStoreUIPlugin(BasePlugin): + """Browse and install community plugins.""" + + name = "Plugin Store" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Community plugin marketplace" + hotkey = "ctrl+shift+slash" + + def initialize(self): + """Setup plugin store.""" + self.available_plugins = [] + self.installed_plugins = [] + self.is_loading = False + + def get_ui(self): + """Create plugin store UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("🛒 Plugin Store") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Search + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search plugins...") + self.search_input.setStyleSheet(""" + QLineEdit { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 4px; + padding: 8px; + } + """) + search_layout.addWidget(self.search_input) + + search_btn = QPushButton("🔍") + search_btn.setFixedSize(32, 32) + search_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + border: none; + border-radius: 4px; + } + """) + search_btn.clicked.connect(self._search_plugins) + search_layout.addWidget(search_btn) + + layout.addLayout(search_layout) + + # Categories + cats_layout = QHBoxLayout() + for cat in ["All", "Hunting", "Mining", "Crafting", "Tools", "Social"]: + btn = QPushButton(cat) + btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,15); + color: white; + border: none; + border-radius: 4px; + padding: 5px 10px; + } + QPushButton:hover { + background-color: rgba(255,255,255,30); + } + """) + cats_layout.addWidget(btn) + cats_layout.addStretch() + layout.addLayout(cats_layout) + + # Plugins list + self.plugins_list = QListWidget() + self.plugins_list.setStyleSheet(""" + QListWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QListWidget::item { + padding: 12px; + border-bottom: 1px solid rgba(100, 110, 130, 40); + } + QListWidget::item:hover { + background-color: rgba(255, 255, 255, 10); + } + """) + self.plugins_list.itemClicked.connect(self._show_plugin_details) + layout.addWidget(self.plugins_list) + + # Sample plugins + self._load_sample_plugins() + + # Details panel + self.details_panel = QFrame() + self.details_panel.setStyleSheet(""" + QFrame { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + """) + details_layout = QVBoxLayout(self.details_panel) + + self.detail_name = QLabel("Select a plugin") + self.detail_name.setStyleSheet("color: #ff8c42; font-size: 14px; font-weight: bold;") + details_layout.addWidget(self.detail_name) + + self.detail_desc = QLabel("") + self.detail_desc.setStyleSheet("color: rgba(255,255,255,150);") + self.detail_desc.setWordWrap(True) + details_layout.addWidget(self.detail_desc) + + self.detail_author = QLabel("") + self.detail_author.setStyleSheet("color: rgba(255,255,255,100); font-size: 10px;") + details_layout.addWidget(self.detail_author) + + self.install_btn = QPushButton("Install") + self.install_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + self.install_btn.clicked.connect(self._install_plugin) + self.install_btn.setEnabled(False) + details_layout.addWidget(self.install_btn) + + layout.addWidget(self.details_panel) + + # Refresh button + refresh_btn = QPushButton("🔄 Refresh") + refresh_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + padding: 8px; + border: none; + border-radius: 4px; + } + """) + refresh_btn.clicked.connect(self._refresh_store) + layout.addWidget(refresh_btn) + + layout.addStretch() + return widget + + def _load_sample_plugins(self): + """Load sample plugin list.""" + sample = [ + { + 'name': 'Crafting Calculator', + 'description': 'Calculate crafting success rates and costs', + 'author': 'EU Community', + 'version': '1.0.0', + 'downloads': 542, + 'rating': 4.5, + }, + { + 'name': 'Global Tracker', + 'description': 'Track globals and HOFs with notifications', + 'author': 'ImpulsiveFPS', + 'version': '1.2.0', + 'downloads': 1203, + 'rating': 4.8, + }, + { + 'name': 'Bank Manager', + 'description': 'Manage storage and bank items across planets', + 'author': 'StorageMaster', + 'version': '0.9.0', + 'downloads': 328, + 'rating': 4.2, + }, + { + 'name': 'Society Tools', + 'description': 'Society management and member tracking', + 'author': 'SocietyDev', + 'version': '1.0.0', + 'downloads': 215, + 'rating': 4.0, + }, + { + 'name': 'Team Helper', + 'description': 'Team coordination and loot sharing', + 'author': 'TeamPlayer', + 'version': '1.1.0', + 'downloads': 876, + 'rating': 4.6, + }, + ] + + self.available_plugins = sample + self._update_list() + + def _update_list(self): + """Update plugins list.""" + self.plugins_list.clear() + + for plugin in self.available_plugins: + item = QListWidgetItem( + f"{plugin['name']} v{plugin['version']}\n" + f"⭐ {plugin['rating']} | ⬇ {plugin['downloads']}" + ) + item.setData(Qt.ItemDataRole.UserRole, plugin) + self.plugins_list.addItem(item) + + def _show_plugin_details(self, item): + """Show plugin details.""" + plugin = item.data(Qt.ItemDataRole.UserRole) + if plugin: + self.detail_name.setText(f"{plugin['name']} v{plugin['version']}") + self.detail_desc.setText(plugin['description']) + self.detail_author.setText(f"By {plugin['author']} | ⭐ {plugin['rating']}") + self.install_btn.setEnabled(True) + self.selected_plugin = plugin + + def _install_plugin(self): + """Install selected plugin.""" + if hasattr(self, 'selected_plugin'): + print(f"Installing {self.selected_plugin['name']}...") + self.install_btn.setText("Installing...") + self.install_btn.setEnabled(False) + # TODO: Actual install + + def _search_plugins(self): + """Search plugins.""" + query = self.search_input.text().lower() + + filtered = [ + p for p in self.available_plugins + if query in p['name'].lower() or query in p['description'].lower() + ] + + self.plugins_list.clear() + for plugin in filtered: + item = QListWidgetItem( + f"{plugin['name']} v{plugin['version']}\n" + f"⭐ {plugin['rating']} | ⬇ {plugin['downloads']}" + ) + item.setData(Qt.ItemDataRole.UserRole, plugin) + self.plugins_list.addItem(item) + + def _refresh_store(self): + """Refresh plugin list.""" + self._load_sample_plugins() diff --git a/plugins/price_alerts/__init__.py b/plugins/price_alerts/__init__.py new file mode 100644 index 0000000..e70c463 --- /dev/null +++ b/plugins/price_alerts/__init__.py @@ -0,0 +1,10 @@ +""" +Price Alerts Plugin for EU-Utility + +Monitor Entropia Nexus prices and get alerts when items +reach target prices. +""" + +from .plugin import PriceAlertPlugin + +__all__ = ['PriceAlertPlugin'] diff --git a/plugins/price_alerts/__pycache__/__init__.cpython-312.pyc b/plugins/price_alerts/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6186895 Binary files /dev/null and b/plugins/price_alerts/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/price_alerts/__pycache__/plugin.cpython-312.pyc b/plugins/price_alerts/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..4d60589 Binary files /dev/null and b/plugins/price_alerts/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/price_alerts/plugin.py b/plugins/price_alerts/plugin.py new file mode 100644 index 0000000..d2d2648 --- /dev/null +++ b/plugins/price_alerts/plugin.py @@ -0,0 +1,693 @@ +""" +EU-Utility - Price Alert Plugin + +Monitor Entropia Nexus API prices and get alerts when items +reach target prices (good for buying low/selling high). +""" + +import json +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field, asdict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTableWidget, QTableWidgetItem, QHeaderView, QLineEdit, + QDoubleSpinBox, QComboBox, QMessageBox, QGroupBox, + QCheckBox, QSpinBox, QSplitter, QTextEdit +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject +from PyQt6.QtGui import QColor + +from plugins.base_plugin import BasePlugin +from core.nexus_api import get_nexus_api, MarketData + + +@dataclass +class PriceAlert: + """A price alert configuration.""" + id: str + item_id: str + item_name: str + alert_type: str # 'below' or 'above' + target_price: float + current_price: Optional[float] = None + last_checked: Optional[datetime] = None + triggered: bool = False + trigger_count: int = 0 + enabled: bool = True + created_at: datetime = field(default_factory=datetime.now) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'id': self.id, + 'item_id': self.item_id, + 'item_name': self.item_name, + 'alert_type': self.alert_type, + 'target_price': self.target_price, + 'current_price': self.current_price, + 'last_checked': self.last_checked.isoformat() if self.last_checked else None, + 'triggered': self.triggered, + 'trigger_count': self.trigger_count, + 'enabled': self.enabled, + 'created_at': self.created_at.isoformat() + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'PriceAlert': + """Create from dictionary.""" + alert = cls( + id=data['id'], + item_id=data['item_id'], + item_name=data['item_name'], + alert_type=data['alert_type'], + target_price=data['target_price'], + current_price=data.get('current_price'), + triggered=data.get('triggered', False), + trigger_count=data.get('trigger_count', 0), + enabled=data.get('enabled', True) + ) + if data.get('last_checked'): + alert.last_checked = datetime.fromisoformat(data['last_checked']) + if data.get('created_at'): + alert.created_at = datetime.fromisoformat(data['created_at']) + return alert + + def check_condition(self, current_price: float) -> bool: + """Check if the alert condition is met.""" + self.current_price = current_price + self.last_checked = datetime.now() + + if self.alert_type == 'below': + return current_price <= self.target_price + elif self.alert_type == 'above': + return current_price >= self.target_price + return False + + +class PriceAlertSignals(QObject): + """Signals for thread-safe UI updates.""" + alert_triggered = pyqtSignal(object) # PriceAlert + prices_updated = pyqtSignal() + + +class PriceAlertPlugin(BasePlugin): + """ + Plugin for monitoring Entropia Nexus prices. + + Features: + - Monitor any item's market price + - Set alerts for price thresholds (buy low, sell high) + - Auto-refresh at configurable intervals + - Notification when alerts trigger + - Price history tracking + """ + + name = "Price Alerts" + version = "1.0.0" + author = "EU-Utility" + description = "Monitor Nexus prices and get alerts" + icon = "🔔" + hotkey = "ctrl+shift+p" + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + + # Alert storage + self.alerts: Dict[str, PriceAlert] = {} + self.price_history: Dict[str, List[Dict]] = {} # item_id -> price history + + # Services + self.nexus = get_nexus_api() + self.signals = PriceAlertSignals() + + # Settings + self.check_interval = self.get_config('check_interval', 300) # 5 minutes + self.notifications_enabled = self.get_config('notifications_enabled', True) + self.sound_enabled = self.get_config('sound_enabled', True) + self.history_days = self.get_config('history_days', 7) + + # UI references + self._ui = None + self.alerts_table = None + self.search_results_table = None + self.search_input = None + self.status_label = None + + # Timer + self._check_timer = None + + # Connect signals + self.signals.alert_triggered.connect(self._on_alert_triggered) + self.signals.prices_updated.connect(self._update_alerts_table) + + # Load saved alerts + self._load_alerts() + + def initialize(self) -> None: + """Initialize plugin.""" + self.log_info("Initializing Price Alerts") + + # Start price check timer + self._check_timer = QTimer() + self._check_timer.timeout.connect(self._check_all_prices) + self._check_timer.start(self.check_interval * 1000) + + # Initial price check + self._check_all_prices() + + self.log_info(f"Price Alerts initialized with {len(self.alerts)} alerts") + + def get_ui(self) -> QWidget: + """Return the plugin's UI widget.""" + if self._ui is None: + self._ui = self._create_ui() + return self._ui + + def _create_ui(self) -> QWidget: + """Create the plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(12) + layout.setContentsMargins(16, 16, 16, 16) + + # Header + header = QLabel("🔔 Price Alerts") + header.setStyleSheet(""" + font-size: 18px; + font-weight: bold; + color: white; + padding-bottom: 8px; + """) + layout.addWidget(header) + + # Status + self.status_label = QLabel(f"Active Alerts: {len(self.alerts)} | Next check: --") + self.status_label.setStyleSheet("color: rgba(255, 255, 255, 150);") + layout.addWidget(self.status_label) + + # Create tabs + tabs = QSplitter(Qt.Orientation.Vertical) + + # === Alerts Tab === + alerts_widget = QWidget() + alerts_layout = QVBoxLayout(alerts_widget) + alerts_layout.setContentsMargins(0, 0, 0, 0) + + alerts_header = QLabel("Your Alerts") + alerts_header.setStyleSheet("font-weight: bold; color: white;") + alerts_layout.addWidget(alerts_header) + + self.alerts_table = QTableWidget() + self.alerts_table.setColumnCount(6) + self.alerts_table.setHorizontalHeaderLabels([ + "Item", "Condition", "Target", "Current", "Status", "Actions" + ]) + self.alerts_table.horizontalHeader().setStretchLastSection(True) + self.alerts_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.alerts_table.setStyleSheet(self._table_style()) + alerts_layout.addWidget(self.alerts_table) + + # Alert buttons + alerts_btn_layout = QHBoxLayout() + + refresh_btn = QPushButton("🔄 Check Now") + refresh_btn.setStyleSheet(self._button_style("#2196f3")) + refresh_btn.clicked.connect(self._check_all_prices) + alerts_btn_layout.addWidget(refresh_btn) + + clear_triggered_btn = QPushButton("🧹 Clear Triggered") + clear_triggered_btn.setStyleSheet(self._button_style()) + clear_triggered_btn.clicked.connect(self._clear_triggered) + alerts_btn_layout.addWidget(clear_triggered_btn) + + alerts_btn_layout.addStretch() + alerts_layout.addLayout(alerts_btn_layout) + + tabs.addWidget(alerts_widget) + + # === Search Tab === + search_widget = QWidget() + search_layout = QVBoxLayout(search_widget) + search_layout.setContentsMargins(0, 0, 0, 0) + + search_header = QLabel("Search Items to Monitor") + search_header.setStyleSheet("font-weight: bold; color: white;") + search_layout.addWidget(search_header) + + # Search input + search_input_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search for items...") + self.search_input.setStyleSheet(self._input_style()) + self.search_input.returnPressed.connect(self._search_items) + search_input_layout.addWidget(self.search_input) + + search_btn = QPushButton("🔍 Search") + search_btn.setStyleSheet(self._button_style("#4caf50")) + search_btn.clicked.connect(self._search_items) + search_input_layout.addWidget(search_btn) + + search_layout.addLayout(search_input_layout) + + # Search results + self.search_results_table = QTableWidget() + self.search_results_table.setColumnCount(4) + self.search_results_table.setHorizontalHeaderLabels(["Name", "Type", "Current Markup", "Action"]) + self.search_results_table.horizontalHeader().setStretchLastSection(True) + self.search_results_table.setStyleSheet(self._table_style()) + search_layout.addWidget(self.search_results_table) + + tabs.addWidget(search_widget) + + layout.addWidget(tabs) + + # Settings + settings_group = QGroupBox("Settings") + settings_layout = QVBoxLayout(settings_group) + + # Check interval + interval_layout = QHBoxLayout() + interval_label = QLabel("Check Interval:") + interval_label.setStyleSheet("color: white;") + interval_layout.addWidget(interval_label) + + self.interval_spin = QSpinBox() + self.interval_spin.setRange(1, 60) + self.interval_spin.setValue(self.check_interval // 60) + self.interval_spin.setSuffix(" min") + self.interval_spin.setStyleSheet(self._spinbox_style()) + interval_layout.addWidget(self.interval_spin) + + interval_layout.addStretch() + settings_layout.addLayout(interval_layout) + + # Notification settings + notif_layout = QHBoxLayout() + + self.notif_checkbox = QCheckBox("Show Notifications") + self.notif_checkbox.setChecked(self.notifications_enabled) + self.notif_checkbox.setStyleSheet("color: white;") + notif_layout.addWidget(self.notif_checkbox) + + self.sound_checkbox = QCheckBox("Play Sound") + self.sound_checkbox.setChecked(self.sound_enabled) + self.sound_checkbox.setStyleSheet("color: white;") + notif_layout.addWidget(self.sound_checkbox) + + notif_layout.addStretch() + settings_layout.addLayout(notif_layout) + + layout.addWidget(settings_group) + + # Apply settings button + apply_btn = QPushButton("💾 Apply Settings") + apply_btn.setStyleSheet(self._button_style("#4caf50")) + apply_btn.clicked.connect(self._apply_settings) + layout.addWidget(apply_btn) + + # Initial table population + self._update_alerts_table() + + return widget + + def _table_style(self) -> str: + """Generate table stylesheet.""" + return """ + QTableWidget { + background-color: rgba(30, 35, 45, 150); + border: 1px solid rgba(100, 150, 200, 50); + border-radius: 8px; + color: white; + gridline-color: rgba(100, 150, 200, 30); + } + QHeaderView::section { + background-color: rgba(50, 60, 75, 200); + color: white; + padding: 8px; + border: none; + } + QTableWidget::item { + padding: 6px; + } + QTableWidget::item:selected { + background-color: rgba(100, 150, 200, 100); + } + """ + + def _button_style(self, color: str = "#607d8b") -> str: + """Generate button stylesheet.""" + return f""" + QPushButton {{ + background-color: {color}; + color: white; + border: none; + border-radius: 8px; + padding: 8px 14px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {color}dd; + }} + QPushButton:pressed {{ + background-color: {color}aa; + }} + """ + + def _input_style(self) -> str: + """Generate input stylesheet.""" + return """ + QLineEdit { + background-color: rgba(50, 60, 75, 200); + color: white; + border: 1px solid rgba(100, 150, 200, 100); + border-radius: 8px; + padding: 8px 12px; + } + QLineEdit:focus { + border: 1px solid rgba(100, 180, 255, 150); + } + """ + + def _spinbox_style(self) -> str: + """Generate spinbox stylesheet.""" + return """ + QSpinBox { + background-color: rgba(50, 60, 75, 200); + color: white; + border: 1px solid rgba(100, 150, 200, 100); + border-radius: 4px; + padding: 4px; + } + """ + + def _search_items(self) -> None: + """Search for items via Nexus API.""" + query = self.search_input.text().strip() + if not query: + return + + self.status_label.setText(f"Searching for '{query}'...") + + # Run search in background + self.run_in_background( + self.nexus.search_items, + query, + limit=20, + priority='normal', + on_complete=self._on_search_complete, + on_error=self._on_search_error + ) + + def _on_search_complete(self, results: List[Any]) -> None: + """Handle search completion.""" + self.search_results_table.setRowCount(len(results)) + + for row, item in enumerate(results): + # Name + name_item = QTableWidgetItem(item.name) + name_item.setForeground(QColor("white")) + self.search_results_table.setItem(row, 0, name_item) + + # Type + type_item = QTableWidgetItem(item.type) + type_item.setForeground(QColor("#2196f3")) + self.search_results_table.setItem(row, 1, type_item) + + # Current markup (will be fetched) + markup_item = QTableWidgetItem("Click to check") + markup_item.setForeground(QColor("rgba(255, 255, 255, 100)")) + self.search_results_table.setItem(row, 2, markup_item) + + # Action button + add_btn = QPushButton("+ Alert") + add_btn.setStyleSheet(self._button_style("#4caf50")) + add_btn.clicked.connect(lambda checked, i=item: self._show_add_alert_dialog(i)) + self.search_results_table.setCellWidget(row, 3, add_btn) + + self.status_label.setText(f"Found {len(results)} items") + + def _on_search_error(self, error: Exception) -> None: + """Handle search error.""" + self.status_label.setText(f"Search failed: {str(error)}") + self.notify_error("Search Failed", str(error)) + + def _show_add_alert_dialog(self, item: Any) -> None: + """Show dialog to add a new alert.""" + from PyQt6.QtWidgets import QDialog, QFormLayout, QDialogButtonBox + + dialog = QDialog(self._ui) + dialog.setWindowTitle(f"Add Alert: {item.name}") + dialog.setStyleSheet(""" + QDialog { + background-color: #2a3040; + } + QLabel { + color: white; + } + """) + + layout = QFormLayout(dialog) + + # Alert type + type_combo = QComboBox() + type_combo.addItems(["Price Below", "Price Above"]) + type_combo.setStyleSheet(self._input_style()) + layout.addRow("Alert When:", type_combo) + + # Target price + price_spin = QDoubleSpinBox() + price_spin.setRange(0.01, 1000000) + price_spin.setDecimals(2) + price_spin.setValue(100.0) + price_spin.setSuffix(" %") + price_spin.setStyleSheet(self._spinbox_style()) + layout.addRow("Target Price:", price_spin) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addRow(buttons) + + if dialog.exec() == QDialog.DialogCode.Accepted: + alert_type = 'below' if type_combo.currentIndex() == 0 else 'above' + self._add_alert(item.id, item.name, alert_type, price_spin.value()) + + def _add_alert(self, item_id: str, item_name: str, alert_type: str, target_price: float) -> None: + """Add a new price alert.""" + import uuid + + alert_id = str(uuid.uuid4())[:8] + alert = PriceAlert( + id=alert_id, + item_id=item_id, + item_name=item_name, + alert_type=alert_type, + target_price=target_price + ) + + self.alerts[alert_id] = alert + self._save_alerts() + self._update_alerts_table() + + # Check price immediately + self._check_alert_price(alert) + + self.notify_success("Alert Added", f"Monitoring {item_name}") + self.log_info(f"Added price alert for {item_name}: {alert_type} {target_price}%") + + def _remove_alert(self, alert_id: str) -> None: + """Remove an alert.""" + if alert_id in self.alerts: + del self.alerts[alert_id] + self._save_alerts() + self._update_alerts_table() + self.log_info(f"Removed alert {alert_id}") + + def _check_all_prices(self) -> None: + """Check prices for all enabled alerts.""" + if not self.alerts: + return + + self.status_label.setText("Checking prices...") + + # Check each alert's price + for alert in self.alerts.values(): + if alert.enabled: + self._check_alert_price(alert) + + # Update UI + self._update_alerts_table() + + # Schedule next check + next_check = datetime.now() + timedelta(seconds=self.check_interval) + self.status_label.setText(f"Active Alerts: {len(self.alerts)} | Next check: {next_check.strftime('%H:%M')}") + + def _check_alert_price(self, alert: PriceAlert) -> None: + """Check price for a single alert.""" + try: + market_data = self.nexus.get_market_data(alert.item_id) + if market_data and market_data.current_markup is not None: + current_price = market_data.current_markup + + # Update price history + if alert.item_id not in self.price_history: + self.price_history[alert.item_id] = [] + + self.price_history[alert.item_id].append({ + 'timestamp': datetime.now().isoformat(), + 'price': current_price + }) + + # Trim history + cutoff = datetime.now() - timedelta(days=self.history_days) + self.price_history[alert.item_id] = [ + h for h in self.price_history[alert.item_id] + if datetime.fromisoformat(h['timestamp']) > cutoff + ] + + # Check condition + if alert.check_condition(current_price): + if not alert.triggered: + alert.triggered = True + alert.trigger_count += 1 + self.signals.alert_triggered.emit(alert) + else: + alert.triggered = False + + alert.last_checked = datetime.now() + except Exception as e: + self.log_error(f"Error checking price for {alert.item_name}: {e}") + + def _on_alert_triggered(self, alert: PriceAlert) -> None: + """Handle triggered alert.""" + condition = "dropped below" if alert.alert_type == 'below' else "rose above" + message = f"{alert.item_name} {condition} {alert.target_price:.1f}% (current: {alert.current_price:.1f}%)" + + if self.notifications_enabled: + self.notify("🚨 Price Alert", message, sound=self.sound_enabled) + + if self.sound_enabled: + self.play_sound('alert') + + self.log_info(f"Price alert triggered: {message}") + self._save_alerts() + + def _update_alerts_table(self) -> None: + """Update the alerts table.""" + if not self.alerts_table: + return + + enabled_alerts = [a for a in self.alerts.values() if a.enabled] + self.alerts_table.setRowCount(len(enabled_alerts)) + + for row, alert in enumerate(enabled_alerts): + # Item name + name_item = QTableWidgetItem(alert.item_name) + name_item.setForeground(QColor("white")) + self.alerts_table.setItem(row, 0, name_item) + + # Condition + condition_text = f"Alert when {'below' if alert.alert_type == 'below' else 'above'}" + condition_item = QTableWidgetItem(condition_text) + condition_item.setForeground(QColor("#2196f3")) + self.alerts_table.setItem(row, 1, condition_item) + + # Target price + target_item = QTableWidgetItem(f"{alert.target_price:.1f}%") + target_item.setForeground(QColor("#ffc107")) + self.alerts_table.setItem(row, 2, target_item) + + # Current price + current_text = f"{alert.current_price:.1f}%" if alert.current_price else "--" + current_item = QTableWidgetItem(current_text) + current_item.setForeground(QColor("#4caf50" if alert.current_price else "rgba(255,255,255,100)")) + self.alerts_table.setItem(row, 3, current_item) + + # Status + status_text = "🚨 TRIGGERED" if alert.triggered else ("✅ OK" if alert.last_checked else "⏳ Pending") + status_item = QTableWidgetItem(status_text) + status_color = "#f44336" if alert.triggered else ("#4caf50" if alert.last_checked else "rgba(255,255,255,100)") + status_item.setForeground(QColor(status_color)) + self.alerts_table.setItem(row, 4, status_item) + + # Actions + actions_widget = QWidget() + actions_layout = QHBoxLayout(actions_widget) + actions_layout.setContentsMargins(4, 2, 4, 2) + + remove_btn = QPushButton("🗑️") + remove_btn.setFixedSize(28, 28) + remove_btn.setStyleSheet(""" + QPushButton { + background-color: #f44336; + color: white; + border: none; + border-radius: 4px; + } + """) + remove_btn.clicked.connect(lambda checked, aid=alert.id: self._remove_alert(aid)) + actions_layout.addWidget(remove_btn) + actions_layout.addStretch() + + self.alerts_table.setCellWidget(row, 5, actions_widget) + + self.status_label.setText(f"Active Alerts: {len(self.alerts)}") + + def _clear_triggered(self) -> None: + """Clear all triggered alerts.""" + for alert in self.alerts.values(): + alert.triggered = False + self._save_alerts() + self._update_alerts_table() + + def _apply_settings(self) -> None: + """Apply and save settings.""" + self.check_interval = self.interval_spin.value() * 60 + self.notifications_enabled = self.notif_checkbox.isChecked() + self.sound_enabled = self.sound_checkbox.isChecked() + + self.set_config('check_interval', self.check_interval) + self.set_config('notifications_enabled', self.notifications_enabled) + self.set_config('sound_enabled', self.sound_enabled) + + # Update timer + if self._check_timer: + self._check_timer.setInterval(self.check_interval * 1000) + + self.notify_success("Settings Saved", "Price alert settings updated") + + def _save_alerts(self) -> None: + """Save alerts to storage.""" + alerts_data = {aid: alert.to_dict() for aid, alert in self.alerts.items()} + self.save_data('alerts', alerts_data) + self.save_data('price_history', self.price_history) + + def _load_alerts(self) -> None: + """Load alerts from storage.""" + alerts_data = self.load_data('alerts', {}) + for aid, data in alerts_data.items(): + try: + self.alerts[aid] = PriceAlert.from_dict(data) + except Exception as e: + self.log_error(f"Failed to load alert {aid}: {e}") + + self.price_history = self.load_data('price_history', {}) + + def on_hotkey(self) -> None: + """Handle hotkey press.""" + self._check_all_prices() + self.notify("Price Check", f"Checked {len(self.alerts)} items") + + def shutdown(self) -> None: + """Cleanup on shutdown.""" + self._save_alerts() + + if self._check_timer: + self._check_timer.stop() + + super().shutdown() diff --git a/plugins/profession_scanner/__init__.py b/plugins/profession_scanner/__init__.py new file mode 100644 index 0000000..7248c5f --- /dev/null +++ b/plugins/profession_scanner/__init__.py @@ -0,0 +1,7 @@ +""" +Profession Scanner Plugin +""" + +from .plugin import ProfessionScannerPlugin + +__all__ = ["ProfessionScannerPlugin"] diff --git a/plugins/profession_scanner/__pycache__/__init__.cpython-312.pyc b/plugins/profession_scanner/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..07a8211 Binary files /dev/null and b/plugins/profession_scanner/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/profession_scanner/__pycache__/plugin.cpython-312.pyc b/plugins/profession_scanner/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..25d8863 Binary files /dev/null and b/plugins/profession_scanner/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/profession_scanner/plugin.py b/plugins/profession_scanner/plugin.py new file mode 100644 index 0000000..c35ad5c --- /dev/null +++ b/plugins/profession_scanner/plugin.py @@ -0,0 +1,247 @@ +""" +EU-Utility - Profession Scanner Plugin + +Scan and track profession progress with OCR. +""" + +import re +import json +from datetime import datetime +from pathlib import Path +from decimal import Decimal + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, QProgressBar, + QFrame, QGroupBox, QComboBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +from plugins.base_plugin import BasePlugin + + +class ProfessionOCRThread(QThread): + """OCR scan for professions window.""" + scan_complete = pyqtSignal(dict) + scan_error = pyqtSignal(str) + progress_update = pyqtSignal(str) + + def run(self): + """Perform OCR scan.""" + try: + self.progress_update.emit("Capturing screen...") + + import pyautogui + screenshot = pyautogui.screenshot() + + self.progress_update.emit("Running OCR...") + + # OCR + try: + import easyocr + reader = easyocr.Reader(['en'], verbose=False) + results = reader.readtext(screenshot) + text = '\n'.join([r[1] for r in results]) + except: + import pytesseract + from PIL import Image + text = pytesseract.image_to_string(screenshot) + + # Parse professions + professions = self._parse_professions(text) + self.scan_complete.emit(professions) + + except Exception as e: + self.scan_error.emit(str(e)) + + def _parse_professions(self, text): + """Parse profession data from OCR text.""" + professions = {} + + # Pattern: ProfessionName Rank %Progress + # Example: "Laser Pistoleer (Hit) Elite, 72 68.3%" + lines = text.split('\n') + + for line in lines: + # Match profession with rank and percentage + match = re.search(r'(\w+(?:\s+\w+)*)\s+\(?(\w+)?\)?\s+(Elite|Champion|Astonishing|Remarkable|Outstanding|Marvelous|Prodigious|Amazing|Incredible|Awesome),?\s+(\d+)[,\s]+(\d+\.?\d*)%?', line) + if match: + prof_name = match.group(1).strip() + spec = match.group(2) or "" + rank_name = match.group(3) + rank_num = match.group(4) + progress = float(match.group(5)) + + full_name = f"{prof_name} ({spec})" if spec else prof_name + + professions[full_name] = { + 'rank_name': rank_name, + 'rank_num': int(rank_num), + 'progress': progress, + 'scanned_at': datetime.now().isoformat() + } + + return professions + + +class ProfessionScannerPlugin(BasePlugin): + """Scan and track profession progress.""" + + name = "Profession Scanner" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Track profession ranks and progress" + hotkey = "ctrl+shift+p" + + def initialize(self): + """Setup profession scanner.""" + self.data_file = Path("data/professions.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.professions = {} + self._load_data() + + def _load_data(self): + """Load saved data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.professions = data.get('professions', {}) + except: + pass + + def _save_data(self): + """Save data.""" + with open(self.data_file, 'w') as f: + json.dump({'professions': self.professions}, f, indent=2) + + def get_ui(self): + """Create profession scanner UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Header + header = QLabel("Profession Tracker") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: white;") + layout.addWidget(header) + + # Summary + summary = QHBoxLayout() + self.total_label = QLabel(f"Professions: {len(self.professions)}") + self.total_label.setStyleSheet("color: #4ecdc4; font-weight: bold;") + summary.addWidget(self.total_label) + + summary.addStretch() + layout.addLayout(summary) + + # Scan button + scan_btn = QPushButton("Scan Professions 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_professions) + layout.addWidget(scan_btn) + + # Progress + self.progress_label = QLabel("Ready to scan") + self.progress_label.setStyleSheet("color: rgba(255,255,255,150);") + layout.addWidget(self.progress_label) + + # Professions table + self.prof_table = QTableWidget() + self.prof_table.setColumnCount(4) + self.prof_table.setHorizontalHeaderLabels(["Profession", "Rank", "Level", "Progress"]) + self.prof_table.horizontalHeader().setStretchLastSection(True) + + # Style table + self.prof_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 8px; + font-weight: bold; + } + """) + + layout.addWidget(self.prof_table) + + # Refresh table + self._refresh_table() + + return widget + + def _scan_professions(self): + """Start OCR scan.""" + self.scanner = ProfessionOCRThread() + 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_progress) + self.scanner.start() + + def _on_progress(self, message): + """Update progress.""" + self.progress_label.setText(message) + + def _on_scan_complete(self, professions): + """Handle scan completion.""" + self.professions.update(professions) + self._save_data() + self._refresh_table() + self.progress_label.setText(f"Found {len(professions)} professions") + self.total_label.setText(f"Professions: {len(self.professions)}") + + def _on_scan_error(self, error): + """Handle error.""" + self.progress_label.setText(f"Error: {error}") + + def _refresh_table(self): + """Refresh professions table.""" + self.prof_table.setRowCount(len(self.professions)) + + for i, (name, data) in enumerate(sorted(self.professions.items())): + self.prof_table.setItem(i, 0, QTableWidgetItem(name)) + self.prof_table.setItem(i, 1, QTableWidgetItem(data.get('rank_name', '-'))) + self.prof_table.setItem(i, 2, QTableWidgetItem(str(data.get('rank_num', 0)))) + + # Progress with bar + progress = data.get('progress', 0) + progress_widget = QWidget() + progress_layout = QHBoxLayout(progress_widget) + progress_layout.setContentsMargins(5, 2, 5, 2) + + bar = QProgressBar() + bar.setValue(int(progress)) + bar.setTextVisible(True) + bar.setFormat(f"{progress:.1f}%") + bar.setStyleSheet(""" + QProgressBar { + background-color: rgba(60, 70, 90, 150); + border: none; + border-radius: 3px; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: #ff8c42; + border-radius: 3px; + } + """) + progress_layout.addWidget(bar) + + self.prof_table.setCellWidget(i, 3, progress_widget) diff --git a/plugins/session_exporter/__init__.py b/plugins/session_exporter/__init__.py new file mode 100644 index 0000000..68eccfd --- /dev/null +++ b/plugins/session_exporter/__init__.py @@ -0,0 +1,9 @@ +""" +Session Exporter Plugin for EU-Utility + +Export hunting/mining/crafting sessions to CSV/JSON formats. +""" + +from .plugin import SessionExporterPlugin + +__all__ = ['SessionExporterPlugin'] diff --git a/plugins/session_exporter/__pycache__/__init__.cpython-312.pyc b/plugins/session_exporter/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d73199c Binary files /dev/null and b/plugins/session_exporter/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/session_exporter/__pycache__/plugin.cpython-312.pyc b/plugins/session_exporter/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..a3ad0dc Binary files /dev/null and b/plugins/session_exporter/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/session_exporter/plugin.py b/plugins/session_exporter/plugin.py new file mode 100644 index 0000000..f001c5f --- /dev/null +++ b/plugins/session_exporter/plugin.py @@ -0,0 +1,643 @@ +""" +EU-Utility - Session Exporter Plugin + +Export hunting/mining/crafting sessions to CSV/JSON formats. +Tracks loot, skills gained, globals, and other session data. +""" + +import json +import csv +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Any, Optional +from dataclasses import dataclass, field, asdict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTableWidget, QTableWidgetItem, QHeaderView, QComboBox, + QFileDialog, QMessageBox, QGroupBox, QSpinBox, QCheckBox, + QTabWidget, QTextEdit, QSplitter +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QColor + +from plugins.base_plugin import BasePlugin +from core.event_bus import ( + LootEvent, SkillGainEvent, GlobalEvent, DamageEvent, + EventCategory, get_event_bus +) + + +@dataclass +class SessionEntry: + """Single session entry representing an event.""" + timestamp: datetime + event_type: str + description: str + value: float = 0.0 + details: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class SessionSummary: + """Summary statistics for a session.""" + start_time: datetime + end_time: Optional[datetime] = None + total_loot_tt: float = 0.0 + total_loot_count: int = 0 + globals_count: int = 0 + hofs_count: int = 0 + skill_gains: int = 0 + total_damage_dealt: float = 0.0 + total_damage_taken: float = 0.0 + unique_items: List[str] = field(default_factory=list) + + +class SessionExporterPlugin(BasePlugin): + """ + Plugin for exporting hunting/mining sessions to various formats. + + Features: + - Automatic session tracking from event bus + - Export to CSV or JSON + - Session statistics and summaries + - Configurable auto-export + """ + + name = "Session Exporter" + version = "1.0.0" + author = "EU-Utility" + description = "Export hunting/mining sessions to CSV/JSON" + icon = "📊" + hotkey = "ctrl+shift+e" + + def __init__(self, overlay_window, config): + super().__init__(overlay_window, config) + + # Session state + self.session_active = False + self.session_start_time: Optional[datetime] = None + self.session_entries: List[SessionEntry] = [] + self.session_summary = None + + # Event subscriptions + self._subscriptions: List[str] = [] + + # Auto-export settings + self.auto_export_enabled = self.get_config('auto_export', False) + self.auto_export_interval = self.get_config('auto_export_interval', 300) # 5 minutes + self.auto_export_format = self.get_config('auto_export_format', 'json') + + # Export settings + self.export_directory = self.get_config('export_directory', str(Path.home() / "Documents" / "EU-Sessions")) + Path(self.export_directory).mkdir(parents=True, exist_ok=True) + + # UI references + self._ui = None + self.session_table = None + self.status_label = None + self.stats_label = None + + # Auto-export timer + self._export_timer = None + + def initialize(self) -> None: + """Initialize plugin and subscribe to events.""" + self.log_info("Initializing Session Exporter") + + # Subscribe to events + self._subscriptions.append( + self.subscribe_typed(LootEvent, self._on_loot) + ) + self._subscriptions.append( + self.subscribe_typed(SkillGainEvent, self._on_skill_gain) + ) + self._subscriptions.append( + self.subscribe_typed(GlobalEvent, self._on_global) + ) + self._subscriptions.append( + self.subscribe_typed(DamageEvent, self._on_damage) + ) + + # Start session automatically + self.start_session() + + # Setup auto-export timer if enabled + if self.auto_export_enabled: + self._setup_auto_export() + + self.log_info("Session Exporter initialized") + + def get_ui(self) -> QWidget: + """Return the plugin's UI widget.""" + if self._ui is None: + self._ui = self._create_ui() + return self._ui + + def _create_ui(self) -> QWidget: + """Create the plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(12) + layout.setContentsMargins(16, 16, 16, 16) + + # Header + header = QLabel("📊 Session Exporter") + header.setStyleSheet(""" + font-size: 18px; + font-weight: bold; + color: white; + padding-bottom: 8px; + """) + layout.addWidget(header) + + # Status section + status_group = QGroupBox("Session Status") + status_layout = QVBoxLayout(status_group) + + self.status_label = QLabel("Session: Active") + self.status_label.setStyleSheet("color: #4caf50; font-weight: bold;") + status_layout.addWidget(self.status_label) + + self.stats_label = QLabel(self._get_stats_text()) + self.stats_label.setStyleSheet("color: rgba(255, 255, 255, 150);") + status_layout.addWidget(self.stats_label) + + layout.addWidget(status_group) + + # Control buttons + button_layout = QHBoxLayout() + + self.start_btn = QPushButton("▶️ Start New") + self.start_btn.setStyleSheet(self._button_style()) + self.start_btn.clicked.connect(self.start_session) + button_layout.addWidget(self.start_btn) + + self.stop_btn = QPushButton("⏹️ Stop") + self.stop_btn.setStyleSheet(self._button_style()) + self.stop_btn.clicked.connect(self.stop_session) + button_layout.addWidget(self.stop_btn) + + self.export_csv_btn = QPushButton("📄 Export CSV") + self.export_csv_btn.setStyleSheet(self._button_style("#2196f3")) + self.export_csv_btn.clicked.connect(lambda: self.export_session('csv')) + button_layout.addWidget(self.export_csv_btn) + + self.export_json_btn = QPushButton("📄 Export JSON") + self.export_json_btn.setStyleSheet(self._button_style("#2196f3")) + self.export_json_btn.clicked.connect(lambda: self.export_session('json')) + button_layout.addWidget(self.export_json_btn) + + layout.addLayout(button_layout) + + # Session entries table + table_group = QGroupBox("Session Entries") + table_layout = QVBoxLayout(table_group) + + self.session_table = QTableWidget() + self.session_table.setColumnCount(4) + self.session_table.setHorizontalHeaderLabels(["Time", "Type", "Description", "Value"]) + self.session_table.horizontalHeader().setStretchLastSection(True) + self.session_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.session_table.setStyleSheet(""" + QTableWidget { + background-color: rgba(30, 35, 45, 150); + border: 1px solid rgba(100, 150, 200, 50); + border-radius: 8px; + color: white; + } + QHeaderView::section { + background-color: rgba(50, 60, 75, 200); + color: white; + padding: 8px; + border: none; + } + QTableWidget::item { + padding: 6px; + } + """) + table_layout.addWidget(self.session_table) + + layout.addWidget(table_group) + + # Settings + settings_group = QGroupBox("Settings") + settings_layout = QVBoxLayout(settings_group) + + # Auto-export + auto_export_layout = QHBoxLayout() + self.auto_export_checkbox = QCheckBox("Auto-export every") + self.auto_export_checkbox.setChecked(self.auto_export_enabled) + self.auto_export_checkbox.setStyleSheet("color: white;") + auto_export_layout.addWidget(self.auto_export_checkbox) + + self.auto_export_spin = QSpinBox() + self.auto_export_spin.setRange(1, 60) + self.auto_export_spin.setValue(self.auto_export_interval // 60) + self.auto_export_spin.setSuffix(" min") + self.auto_export_spin.setStyleSheet(""" + QSpinBox { + background-color: rgba(50, 60, 75, 200); + color: white; + border: 1px solid rgba(100, 150, 200, 100); + border-radius: 4px; + padding: 4px; + } + """) + auto_export_layout.addWidget(self.auto_export_spin) + + auto_export_layout.addStretch() + settings_layout.addLayout(auto_export_layout) + + # Export directory + dir_layout = QHBoxLayout() + dir_label = QLabel("Export Directory:") + dir_label.setStyleSheet("color: rgba(255, 255, 255, 150);") + dir_layout.addWidget(dir_label) + + self.dir_display = QLabel(self.export_directory) + self.dir_display.setStyleSheet("color: white;") + self.dir_display.setWordWrap(True) + dir_layout.addWidget(self.dir_display, 1) + + change_dir_btn = QPushButton("📁 Change") + change_dir_btn.setStyleSheet(self._button_style()) + change_dir_btn.clicked.connect(self._change_export_directory) + dir_layout.addWidget(change_dir_btn) + + settings_layout.addLayout(dir_layout) + + layout.addWidget(settings_group) + + # Apply settings button + apply_btn = QPushButton("💾 Apply Settings") + apply_btn.setStyleSheet(self._button_style("#4caf50")) + apply_btn.clicked.connect(self._apply_settings) + layout.addWidget(apply_btn) + + layout.addStretch() + + return widget + + def _button_style(self, color: str = "#607d8b") -> str: + """Generate button stylesheet.""" + return f""" + QPushButton {{ + background-color: {color}; + color: white; + border: none; + border-radius: 8px; + padding: 10px 16px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {color}dd; + }} + QPushButton:pressed {{ + background-color: {color}aa; + }} + """ + + def start_session(self) -> None: + """Start a new tracking session.""" + self.session_active = True + self.session_start_time = datetime.now() + self.session_entries = [] + self.session_summary = SessionSummary(start_time=self.session_start_time) + + self.log_info(f"Session started at {self.session_start_time}") + + if self.status_label: + self.status_label.setText(f"Session: Active (started {self.session_start_time.strftime('%H:%M:%S')})") + self.status_label.setStyleSheet("color: #4caf50; font-weight: bold;") + + self.notify("Session Started", "Tracking loot, skills, and globals") + + def stop_session(self) -> None: + """Stop the current tracking session.""" + if not self.session_active: + return + + self.session_active = False + if self.session_summary: + self.session_summary.end_time = datetime.now() + + self.log_info("Session stopped") + + if self.status_label: + duration = "" + if self.session_summary and self.session_summary.end_time: + duration_secs = (self.session_summary.end_time - self.session_start_time).total_seconds() + mins, secs = divmod(int(duration_secs), 60) + hours, mins = divmod(mins, 60) + duration = f"Duration: {hours:02d}:{mins:02d}:{secs:02d}" + + self.status_label.setText(f"Session: Stopped ({duration})") + self.status_label.setStyleSheet("color: #ff9800; font-weight: bold;") + + self.notify("Session Stopped", f"Total entries: {len(self.session_entries)}") + + def _on_loot(self, event: LootEvent) -> None: + """Handle loot events.""" + if not self.session_active: + return + + item_names = ", ".join(event.get_item_names()) + entry = SessionEntry( + timestamp=event.timestamp, + event_type="Loot", + description=f"From {event.mob_name}: {item_names}", + value=event.total_tt_value, + details={ + 'mob_name': event.mob_name, + 'items': event.items, + 'position': event.position + } + ) + + self.session_entries.append(entry) + + if self.session_summary: + self.session_summary.total_loot_tt += event.total_tt_value + self.session_summary.total_loot_count += 1 + for item in event.get_item_names(): + if item not in self.session_summary.unique_items: + self.session_summary.unique_items.append(item) + + self._update_ui() + + def _on_skill_gain(self, event: SkillGainEvent) -> None: + """Handle skill gain events.""" + if not self.session_active: + return + + entry = SessionEntry( + timestamp=event.timestamp, + event_type="Skill", + description=f"{event.skill_name} +{event.gain_amount:.4f}", + value=event.gain_amount, + details={ + 'skill_name': event.skill_name, + 'skill_value': event.skill_value, + 'gain_amount': event.gain_amount + } + ) + + self.session_entries.append(entry) + + if self.session_summary: + self.session_summary.skill_gains += 1 + + self._update_ui() + + def _on_global(self, event: GlobalEvent) -> None: + """Handle global/HOF events.""" + if not self.session_active: + return + + is_hof = event.achievement_type.lower() in ['hof', 'ath', 'discovery'] + + entry = SessionEntry( + timestamp=event.timestamp, + event_type="HOF" if is_hof else "Global", + description=f"{event.player_name}: {event.item_name or 'Value'} worth {event.value:.0f} PED", + value=event.value, + details={ + 'player_name': event.player_name, + 'achievement_type': event.achievement_type, + 'item_name': event.item_name + } + ) + + self.session_entries.append(entry) + + if self.session_summary: + if is_hof: + self.session_summary.hofs_count += 1 + else: + self.session_summary.globals_count += 1 + + self._update_ui() + + def _on_damage(self, event: DamageEvent) -> None: + """Handle damage events.""" + if not self.session_active: + return + + if event.is_outgoing: + if self.session_summary: + self.session_summary.total_damage_dealt += event.damage_amount + else: + if self.session_summary: + self.session_summary.total_damage_taken += event.damage_amount + + self._update_ui() + + def _update_ui(self) -> None: + """Update the UI with current session data.""" + if not self._ui or not self.session_table: + return + + # Update stats label + if self.stats_label: + self.stats_label.setText(self._get_stats_text()) + + # Update table (show last 50 entries) + recent_entries = self.session_entries[-50:] + self.session_table.setRowCount(len(recent_entries)) + + type_colors = { + 'Loot': '#4caf50', + 'Skill': '#2196f3', + 'Global': '#ff9800', + 'HOF': '#f44336' + } + + for row, entry in enumerate(recent_entries): + # Time + time_item = QTableWidgetItem(entry.timestamp.strftime("%H:%M:%S")) + time_item.setForeground(QColor("white")) + self.session_table.setItem(row, 0, time_item) + + # Type + type_item = QTableWidgetItem(entry.event_type) + type_item.setForeground(QColor(type_colors.get(entry.event_type, 'white'))) + self.session_table.setItem(row, 1, type_item) + + # Description + desc_item = QTableWidgetItem(entry.description) + desc_item.setForeground(QColor("white")) + self.session_table.setItem(row, 2, desc_item) + + # Value + value_item = QTableWidgetItem(f"{entry.value:.2f}") + value_item.setForeground(QColor("#ffc107")) + self.session_table.setItem(row, 3, value_item) + + # Scroll to bottom + self.session_table.scrollToBottom() + + def _get_stats_text(self) -> str: + """Get formatted statistics text.""" + if not self.session_summary: + return "No active session" + + lines = [ + f"Entries: {len(self.session_entries)}", + f"Loot: {self.session_summary.total_loot_count} items, {self.session_summary.total_loot_tt:.2f} PED TT", + f"Globals: {self.session_summary.globals_count} | HOFs: {self.session_summary.hofs_count}", + f"Skills: {self.session_summary.skill_gains}", + ] + + if self.session_summary.total_damage_dealt > 0: + lines.append(f"Damage Dealt: {self.session_summary.total_damage_dealt:,.0f}") + + return " | ".join(lines) + + def export_session(self, format_type: str = 'json') -> Optional[Path]: + """ + Export the current session to a file. + + Args: + format_type: 'json' or 'csv' + + Returns: + Path to exported file or None if failed + """ + if not self.session_entries: + self.notify_warning("Export Failed", "No session data to export") + return None + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"session_{timestamp}.{format_type}" + filepath = Path(self.export_directory) / filename + + try: + if format_type == 'json': + self._export_json(filepath) + elif format_type == 'csv': + self._export_csv(filepath) + else: + raise ValueError(f"Unknown format: {format_type}") + + self.notify_success("Export Complete", f"Saved to {filename}") + self.log_info(f"Session exported to {filepath}") + return filepath + + except Exception as e: + self.notify_error("Export Failed", str(e)) + self.log_error(f"Export failed: {e}") + return None + + def _export_json(self, filepath: Path) -> None: + """Export session to JSON format.""" + data = { + 'export_info': { + 'version': '1.0.0', + 'exported_at': datetime.now().isoformat(), + 'plugin': 'Session Exporter' + }, + 'session': { + 'start_time': self.session_start_time.isoformat() if self.session_start_time else None, + 'end_time': self.session_summary.end_time.isoformat() if self.session_summary and self.session_summary.end_time else None, + 'summary': asdict(self.session_summary) if self.session_summary else {}, + 'entries': [ + { + 'timestamp': e.timestamp.isoformat(), + 'event_type': e.event_type, + 'description': e.description, + 'value': e.value, + 'details': e.details + } + for e in self.session_entries + ] + } + } + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def _export_csv(self, filepath: Path) -> None: + """Export session to CSV format.""" + with open(filepath, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + + # Write header + writer.writerow(['timestamp', 'event_type', 'description', 'value', 'details']) + + # Write entries + for entry in self.session_entries: + writer.writerow([ + entry.timestamp.isoformat(), + entry.event_type, + entry.description, + entry.value, + json.dumps(entry.details) + ]) + + def _change_export_directory(self) -> None: + """Change the export directory.""" + new_dir = QFileDialog.getExistingDirectory( + self._ui, + "Select Export Directory", + self.export_directory + ) + + if new_dir: + self.export_directory = new_dir + if self.dir_display: + self.dir_display.setText(new_dir) + + def _apply_settings(self) -> None: + """Apply and save settings.""" + self.auto_export_enabled = self.auto_export_checkbox.isChecked() + self.auto_export_interval = self.auto_export_spin.value() * 60 + + self.set_config('auto_export', self.auto_export_enabled) + self.set_config('auto_export_interval', self.auto_export_interval) + self.set_config('export_directory', self.export_directory) + + # Update auto-export timer + if self.auto_export_enabled: + self._setup_auto_export() + elif self._export_timer: + self._export_timer.stop() + + self.notify_success("Settings Saved", "Session exporter settings updated") + + def _setup_auto_export(self) -> None: + """Setup auto-export timer.""" + if self._export_timer: + self._export_timer.stop() + + self._export_timer = QTimer() + self._export_timer.timeout.connect(lambda: self.export_session(self.auto_export_format)) + self._export_timer.start(self.auto_export_interval * 1000) + + self.log_info(f"Auto-export enabled every {self.auto_export_interval // 60} minutes") + + def on_hotkey(self) -> None: + """Handle hotkey press.""" + if self.session_active: + self.stop_session() + else: + self.start_session() + + def shutdown(self) -> None: + """Cleanup on shutdown.""" + # Auto-export on shutdown if enabled + if self.auto_export_enabled and self.session_entries: + self.export_session(self.auto_export_format) + + # Stop session + if self.session_active: + self.stop_session() + + # Unsubscribe from events + for sub_id in self._subscriptions: + self.unsubscribe_typed(sub_id) + + if self._export_timer: + self._export_timer.stop() + + super().shutdown() diff --git a/plugins/settings/__init__.py b/plugins/settings/__init__.py new file mode 100644 index 0000000..b8a1ea7 --- /dev/null +++ b/plugins/settings/__init__.py @@ -0,0 +1,7 @@ +""" +Settings Plugin +""" + +from .plugin import SettingsPlugin + +__all__ = ["SettingsPlugin"] diff --git a/plugins/settings/__pycache__/__init__.cpython-312.pyc b/plugins/settings/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..03633eb Binary files /dev/null and b/plugins/settings/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/settings/__pycache__/plugin.cpython-312.pyc b/plugins/settings/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..9595ddf Binary files /dev/null and b/plugins/settings/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/settings/plugin.py b/plugins/settings/plugin.py new file mode 100644 index 0000000..59659ac --- /dev/null +++ b/plugins/settings/plugin.py @@ -0,0 +1,1174 @@ +""" +EU-Utility - Settings UI Plugin + +Settings menu for configuring EU-Utility. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QCheckBox, QLineEdit, QComboBox, + QSlider, QTabWidget, QGroupBox, QListWidget, + QListWidgetItem, QFrame, QFileDialog, QScrollArea +) +from PyQt6.QtCore import Qt, QTimer + +from core.settings import get_settings +from plugins.base_plugin import BasePlugin + + +class SettingsPlugin(BasePlugin): + """EU-Utility settings and configuration.""" + + name = "Settings" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Configure EU-Utility preferences" + hotkey = "ctrl+shift+comma" + + def initialize(self): + """Setup settings.""" + self.settings = get_settings() + + def get_ui(self): + """Create settings UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("Settings") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Tabs + tabs = QTabWidget() + tabs.setStyleSheet(""" + QTabBar::tab { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,150); + padding: 10px 20px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + } + QTabBar::tab:selected { + background-color: #ff8c42; + color: white; + font-weight: bold; + } + """) + + # General tab + general_tab = self._create_general_tab() + tabs.addTab(general_tab, "General") + + # Plugins tab + plugins_tab = self._create_plugins_tab() + tabs.addTab(plugins_tab, "Plugins") + + # Hotkeys tab + hotkeys_tab = self._create_hotkeys_tab() + tabs.addTab(hotkeys_tab, "Hotkeys") + + # Overlay tab + overlay_tab = self._create_overlay_tab() + tabs.addTab(overlay_tab, "Overlays") + + # Data tab + data_tab = self._create_data_tab() + tabs.addTab(data_tab, "Data") + + layout.addWidget(tabs) + + # Save/Reset buttons + btn_layout = QHBoxLayout() + + save_btn = QPushButton("Save Settings") + save_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + save_btn.clicked.connect(self._save_settings) + btn_layout.addWidget(save_btn) + + reset_btn = QPushButton("Reset to Default") + reset_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + } + """) + reset_btn.clicked.connect(self._reset_settings) + btn_layout.addWidget(reset_btn) + + btn_layout.addStretch() + layout.addLayout(btn_layout) + + return widget + + def _create_general_tab(self): + """Create general settings tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + # Appearance + appear_group = QGroupBox("Appearance") + appear_group.setStyleSheet(self._group_style()) + appear_layout = QVBoxLayout(appear_group) + + # Theme + theme_layout = QHBoxLayout() + theme_layout.addWidget(QLabel("Theme:")) + self.theme_combo = QComboBox() + self.theme_combo.addItems(["Dark (EU Style)", "Light", "Auto"]) + self.theme_combo.setCurrentText(self.settings.get('theme', 'Dark (EU Style)')) + theme_layout.addWidget(self.theme_combo) + theme_layout.addStretch() + appear_layout.addLayout(theme_layout) + + # Opacity + opacity_layout = QHBoxLayout() + opacity_layout.addWidget(QLabel("Overlay Opacity:")) + self.opacity_slider = QSlider(Qt.Orientation.Horizontal) + self.opacity_slider.setMinimum(50) + self.opacity_slider.setMaximum(100) + self.opacity_slider.setValue(int(self.settings.get('overlay_opacity', 0.9) * 100)) + opacity_layout.addWidget(self.opacity_slider) + self.opacity_label = QLabel(f"{self.opacity_slider.value()}%") + opacity_layout.addWidget(self.opacity_label) + opacity_layout.addStretch() + appear_layout.addLayout(opacity_layout) + + # Icon size + icon_layout = QHBoxLayout() + icon_layout.addWidget(QLabel("Icon Size:")) + self.icon_combo = QComboBox() + self.icon_combo.addItems(["Small (20px)", "Medium (24px)", "Large (32px)"]) + icon_layout.addWidget(self.icon_combo) + icon_layout.addStretch() + appear_layout.addLayout(icon_layout) + + layout.addWidget(appear_group) + + # Behavior + behavior_group = QGroupBox("Behavior") + behavior_group.setStyleSheet(self._group_style()) + behavior_layout = QVBoxLayout(behavior_group) + + self.auto_start_cb = QCheckBox("Start with Windows") + self.auto_start_cb.setChecked(self.settings.get('auto_start', False)) + behavior_layout.addWidget(self.auto_start_cb) + + self.minimize_cb = QCheckBox("Minimize to tray on close") + self.minimize_cb.setChecked(self.settings.get('minimize_to_tray', True)) + behavior_layout.addWidget(self.minimize_cb) + + self.tooltips_cb = QCheckBox("Show tooltips") + self.tooltips_cb.setChecked(self.settings.get('show_tooltips', True)) + behavior_layout.addWidget(self.tooltips_cb) + + layout.addWidget(behavior_group) + layout.addStretch() + + return tab + + def _create_plugins_tab(self): + """Create plugins management tab with dependency visualization.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + # Info label + info = QLabel("Manage plugins. Hover over dependency icons to see requirements. Changes take effect immediately.") + info.setStyleSheet("color: rgba(255,255,255,150);") + layout.addWidget(info) + + # Dependency legend + legend_layout = QHBoxLayout() + legend_layout.addWidget(QLabel("Legend:")) + + # Required by others indicator + req_label = QLabel("⚠️ Required") + req_label.setStyleSheet("color: #ffd93d; font-size: 11px;") + req_label.setToolTip("This plugin is required by other enabled plugins") + legend_layout.addWidget(req_label) + + # Has dependencies indicator + dep_label = QLabel("🔗 Has deps") + dep_label.setStyleSheet("color: #4ecdc4; font-size: 11px;") + dep_label.setToolTip("This plugin requires other plugins to function") + legend_layout.addWidget(dep_label) + + # Auto-enabled indicator + auto_label = QLabel("🔄 Auto") + auto_label.setStyleSheet("color: #ff8c42; font-size: 11px;") + auto_label.setToolTip("Auto-enabled due to dependency") + legend_layout.addWidget(auto_label) + + legend_layout.addStretch() + layout.addLayout(legend_layout) + + # Scroll area for plugin list + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent; border: none;") + + scroll_content = QWidget() + plugins_layout = QVBoxLayout(scroll_content) + plugins_layout.setSpacing(8) + plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.plugin_checkboxes = {} + self.plugin_dependency_labels = {} + self.plugin_rows = {} + + # Get all discovered plugins from plugin manager + if hasattr(self.overlay, 'plugin_manager'): + plugin_manager = self.overlay.plugin_manager + all_plugins = plugin_manager.get_all_discovered_plugins() + + # Build dependency maps + self._build_dependency_maps(all_plugins) + + # Sort by name + sorted_plugins = sorted(all_plugins.items(), key=lambda x: x[1].name) + + for plugin_id, plugin_class in sorted_plugins: + plugin_row = self._create_plugin_row(plugin_id, plugin_class, plugin_manager) + plugins_layout.addLayout(plugin_row) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("background-color: rgba(100, 110, 130, 40);") + sep.setFixedHeight(1) + plugins_layout.addWidget(sep) + + plugins_layout.addStretch() + scroll.setWidget(scroll_content) + layout.addWidget(scroll) + + # Buttons + btn_layout = QHBoxLayout() + + enable_all_btn = QPushButton("Enable All") + enable_all_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + } + """) + enable_all_btn.clicked.connect(self._enable_all_plugins) + btn_layout.addWidget(enable_all_btn) + + disable_all_btn = QPushButton("Disable All") + disable_all_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + } + """) + disable_all_btn.clicked.connect(self._disable_all_plugins) + btn_layout.addWidget(disable_all_btn) + + # Dependency info button + deps_info_btn = QPushButton("📋 Dependency Report") + deps_info_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + } + """) + deps_info_btn.clicked.connect(self._show_dependency_report) + btn_layout.addWidget(deps_info_btn) + + btn_layout.addStretch() + layout.addLayout(btn_layout) + + return tab + + def _build_dependency_maps(self, all_plugins): + """Build maps of plugin dependencies.""" + self.plugin_deps = {} # plugin_id -> list of plugin_ids it depends on + self.plugin_dependents = {} # plugin_id -> list of plugin_ids that depend on it + + for plugin_id, plugin_class in all_plugins.items(): + deps = getattr(plugin_class, 'dependencies', {}) + plugin_deps_list = deps.get('plugins', []) + + self.plugin_deps[plugin_id] = plugin_deps_list + + # Build reverse map + for dep_id in plugin_deps_list: + if dep_id not in self.plugin_dependents: + self.plugin_dependents[dep_id] = [] + self.plugin_dependents[dep_id].append(plugin_id) + + def _create_plugin_row(self, plugin_id, plugin_class, plugin_manager): + """Create a plugin row with dependency indicators.""" + row = QHBoxLayout() + row.setSpacing(10) + + # Checkbox + cb = QCheckBox(plugin_class.name) + is_enabled = plugin_manager.is_plugin_enabled(plugin_id) + is_auto_enabled = plugin_manager.is_auto_enabled(plugin_id) if hasattr(plugin_manager, 'is_auto_enabled') else False + + cb.setChecked(is_enabled) + cb.setStyleSheet(""" + QCheckBox { + color: white; + spacing: 8px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + } + QCheckBox::indicator:disabled { + background-color: #ff8c42; + } + """) + + # Disable checkbox if auto-enabled + if is_auto_enabled: + cb.setEnabled(False) + cb.setText(f"{plugin_class.name} (auto)") + + # Connect to enable/disable + cb.stateChanged.connect( + lambda state, pid=plugin_id: self._toggle_plugin(pid, state == Qt.CheckState.Checked.value) + ) + self.plugin_checkboxes[plugin_id] = cb + row.addWidget(cb) + + # Dependency indicators + indicators_layout = QHBoxLayout() + indicators_layout.setSpacing(4) + + # Check if this plugin has dependencies + deps = self.plugin_deps.get(plugin_id, []) + if deps: + deps_btn = QPushButton("🔗") + deps_btn.setFixedSize(24, 24) + deps_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #4ecdc4; + border: none; + font-size: 12px; + } + QPushButton:hover { + background-color: rgba(78, 205, 196, 30); + border-radius: 4px; + } + """) + deps_btn.setToolTip(self._format_dependencies_tooltip(plugin_id, deps)) + indicators_layout.addWidget(deps_btn) + + # Check if other plugins depend on this one + dependents = self.plugin_dependents.get(plugin_id, []) + enabled_dependents = [d for d in dependents if plugin_manager.is_plugin_enabled(d)] + if enabled_dependents: + req_btn = QPushButton("⚠️") + req_btn.setFixedSize(24, 24) + req_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #ffd93d; + border: none; + font-size: 12px; + } + QPushButton:hover { + background-color: rgba(255, 217, 61, 30); + border-radius: 4px; + } + """) + req_btn.setToolTip(self._format_dependents_tooltip(plugin_id, enabled_dependents)) + indicators_layout.addWidget(req_btn) + + # Check if auto-enabled + if is_auto_enabled: + auto_btn = QPushButton("🔄") + auto_btn.setFixedSize(24, 24) + auto_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #ff8c42; + border: none; + font-size: 12px; + } + QPushButton:hover { + background-color: rgba(255, 140, 66, 30); + border-radius: 4px; + } + """) + # Find what enabled this plugin + enabler = self._find_enabler(plugin_id, plugin_manager) + auto_btn.setToolTip(f"Auto-enabled by: {enabler or 'dependency resolution'}") + indicators_layout.addWidget(auto_btn) + + row.addLayout(indicators_layout) + + # Version + version_label = QLabel(f"v{plugin_class.version}") + version_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") + row.addWidget(version_label) + + # Description + desc_label = QLabel(f"- {plugin_class.description}") + desc_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") + desc_label.setWordWrap(True) + row.addWidget(desc_label, 1) + + row.addStretch() + + # Store row reference for updates + self.plugin_rows[plugin_id] = row + + return row + + def _format_dependencies_tooltip(self, plugin_id, deps): + """Format tooltip for dependencies.""" + lines = ["This plugin requires:"] + for dep_id in deps: + dep_name = dep_id.split('.')[-1].replace('_', ' ').title() + lines.append(f" • {dep_name}") + lines.append("") + lines.append("These will be auto-enabled when you enable this plugin.") + return "\n".join(lines) + + def _format_dependents_tooltip(self, plugin_id, dependents): + """Format tooltip for plugins that depend on this one.""" + lines = ["Required by enabled plugins:"] + for dep_id in dependents: + dep_name = dep_id.split('.')[-1].replace('_', ' ').title() + lines.append(f" • {dep_name}") + lines.append("") + lines.append("Disable these first to disable this plugin.") + return "\n".join(lines) + + def _find_enabler(self, plugin_id, plugin_manager): + """Find which plugin auto-enabled this one.""" + # Check all enabled plugins to see which one depends on this + for other_id, other_class in plugin_manager.get_all_discovered_plugins().items(): + if plugin_manager.is_plugin_enabled(other_id): + deps = getattr(other_class, 'dependencies', {}).get('plugins', []) + if plugin_id in deps: + return other_class.name + return None + + def _show_dependency_report(self): + """Show a dialog with full dependency report.""" + from PyQt6.QtWidgets import QDialog, QTextEdit, QVBoxLayout, QPushButton + + dialog = QDialog() + dialog.setWindowTitle("Plugin Dependency Report") + dialog.setMinimumSize(600, 400) + dialog.setStyleSheet(""" + QDialog { + background-color: #1a1f2e; + } + QTextEdit { + background-color: #232837; + color: white; + border: 1px solid rgba(100, 110, 130, 80); + padding: 10px; + } + QPushButton { + background-color: #4a9eff; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + } + """) + + layout = QVBoxLayout(dialog) + + text_edit = QTextEdit() + text_edit.setReadOnly(True) + text_edit.setHtml(self._generate_dependency_report()) + layout.addWidget(text_edit) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.close) + layout.addWidget(close_btn) + + dialog.exec() + + def _create_hotkeys_tab(self): + """Create hotkeys configuration tab - dynamically discovers hotkeys from plugins.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + # Info label + info = QLabel("Hotkeys are advertised by plugins. Changes apply on next restart.") + info.setStyleSheet("color: rgba(255,255,255,150);") + layout.addWidget(info) + + # Scroll area for hotkeys + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent; border: none;") + + scroll_content = QWidget() + hotkeys_layout = QVBoxLayout(scroll_content) + hotkeys_layout.setSpacing(10) + hotkeys_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.hotkey_inputs = {} + + # Collect hotkeys from all plugins + plugin_hotkeys = self._collect_plugin_hotkeys() + + # Group by plugin + for plugin_name, hotkeys in sorted(plugin_hotkeys.items()): + # Plugin group + group = QGroupBox(plugin_name) + group.setStyleSheet(self._group_style()) + group_layout = QVBoxLayout(group) + + for hotkey_info in hotkeys: + row = QHBoxLayout() + + # Description + desc = hotkey_info.get('description', hotkey_info['action']) + desc_label = QLabel(f"{desc}:") + desc_label.setStyleSheet("color: white; min-width: 150px;") + row.addWidget(desc_label) + + # Hotkey input + input_field = QLineEdit() + input_field.setText(hotkey_info['current']) + input_field.setPlaceholderText(hotkey_info['default']) + input_field.setStyleSheet(""" + QLineEdit { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + padding: 5px; + min-width: 150px; + } + """) + + # Store reference with config key + config_key = hotkey_info['config_key'] + self.hotkey_inputs[config_key] = { + 'input': input_field, + 'default': hotkey_info['default'], + 'plugin': plugin_name, + 'action': hotkey_info['action'] + } + + row.addWidget(input_field) + + # Reset button + reset_btn = QPushButton("↺") + reset_btn.setFixedSize(28, 28) + reset_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + } + QPushButton:hover { + background-color: rgba(255,255,255,40); + } + """) + reset_btn.setToolTip(f"Reset to default: {hotkey_info['default']}") + reset_btn.clicked.connect(lambda checked, inp=input_field, default=hotkey_info['default']: inp.setText(default)) + row.addWidget(reset_btn) + + row.addStretch() + group_layout.addLayout(row) + + hotkeys_layout.addWidget(group) + + # Core hotkeys section (always present) + core_group = QGroupBox("Core System") + core_group.setStyleSheet(self._group_style()) + core_layout = QVBoxLayout(core_group) + + core_hotkeys = [ + ("Toggle Overlay", "hotkey_toggle", "ctrl+shift+u", "Show/hide the EU-Utility overlay"), + ("Universal Search", "hotkey_search", "ctrl+shift+f", "Quick search across all plugins"), + ] + + for label, config_key, default, description in core_hotkeys: + row = QHBoxLayout() + + desc_label = QLabel(f"{label}:") + desc_label.setStyleSheet("color: white; min-width: 150px;") + row.addWidget(desc_label) + + input_field = QLineEdit() + current = self.settings.get(config_key, default) + input_field.setText(current) + input_field.setPlaceholderText(default) + input_field.setStyleSheet(""" + QLineEdit { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + padding: 5px; + min-width: 150px; + } + """) + + self.hotkey_inputs[config_key] = { + 'input': input_field, + 'default': default, + 'plugin': 'Core', + 'action': label + } + + row.addWidget(input_field) + + reset_btn = QPushButton("↺") + reset_btn.setFixedSize(28, 28) + reset_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + } + QPushButton:hover { + background-color: rgba(255,255,255,40); + } + """) + reset_btn.setToolTip(f"Reset to default: {default}") + reset_btn.clicked.connect(lambda checked, inp=input_field, default=default: inp.setText(default)) + row.addWidget(reset_btn) + + row.addStretch() + core_layout.addLayout(row) + + hotkeys_layout.addWidget(core_group) + hotkeys_layout.addStretch() + + scroll.setWidget(scroll_content) + layout.addWidget(scroll) + + return tab + + def _collect_plugin_hotkeys(self) -> dict: + """Collect hotkeys from all discovered plugins. + + Returns: + Dict mapping plugin name to list of hotkey info dicts + """ + plugin_hotkeys = {} + + if not hasattr(self.overlay, 'plugin_manager'): + return plugin_hotkeys + + plugin_manager = self.overlay.plugin_manager + all_plugins = plugin_manager.get_all_discovered_plugins() + + for plugin_id, plugin_class in all_plugins.items(): + hotkeys = getattr(plugin_class, 'hotkeys', None) + + if not hotkeys: + # Try legacy single hotkey attribute + single_hotkey = getattr(plugin_class, 'hotkey', None) + if single_hotkey: + hotkeys = [{ + 'action': 'toggle', + 'description': f"Toggle {plugin_class.name}", + 'default': single_hotkey, + 'config_key': f"hotkey_{plugin_id.split('.')[-1]}" + }] + + if hotkeys: + plugin_name = plugin_class.name + plugin_hotkeys[plugin_name] = [] + + for i, hk in enumerate(hotkeys): + # Support both dict format and simple string + if isinstance(hk, dict): + hotkey_info = { + 'action': hk.get('action', f'action_{i}'), + 'description': hk.get('description', hk.get('action', f'Action {i}')), + 'default': hk.get('default', ''), + 'config_key': hk.get('config_key', f"hotkey_{plugin_id.split('.')[-1]}_{i}") + } + else: + # Simple string format - legacy + hotkey_info = { + 'action': f'hotkey_{i}', + 'description': f"Hotkey {i+1}", + 'default': str(hk), + 'config_key': f"hotkey_{plugin_id.split('.')[-1]}_{i}" + } + + # Get current value from settings + hotkey_info['current'] = self.settings.get(hotkey_info['config_key'], hotkey_info['default']) + + plugin_hotkeys[plugin_name].append(hotkey_info) + + return plugin_hotkeys + + def _create_overlay_tab(self): + """Create overlay widgets configuration tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + overlays_group = QGroupBox("In-Game Overlays") + overlays_group.setStyleSheet(self._group_style()) + overlays_layout = QVBoxLayout(overlays_group) + + overlays = [ + ("Spotify Player", "spotify", True), + ("Mission Tracker", "mission", False), + ("Skill Gains", "skillgain", False), + ("DPP Tracker", "dpp", False), + ] + + for name, key, enabled in overlays: + cb = QCheckBox(name) + cb.setChecked(enabled) + overlays_layout.addWidget(cb) + + # Reset positions + reset_pos_btn = QPushButton("↺ Reset All Positions") + reset_pos_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + padding: 8px; + border: none; + border-radius: 4px; + } + """) + overlays_layout.addWidget(reset_pos_btn) + + layout.addWidget(overlays_group) + layout.addStretch() + + return tab + + def _create_data_tab(self): + """Create data management tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + data_group = QGroupBox("Data Management") + data_group.setStyleSheet(self._group_style()) + data_layout = QVBoxLayout(data_group) + + # Export + export_btn = QPushButton("📤 Export All Data") + export_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + export_btn.clicked.connect(self._export_data) + data_layout.addWidget(export_btn) + + # Import + import_btn = QPushButton("📥 Import Data") + import_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255,255,255,20); + color: white; + padding: 10px; + border: none; + border-radius: 4px; + } + """) + import_btn.clicked.connect(self._import_data) + data_layout.addWidget(import_btn) + + # Clear + clear_btn = QPushButton("Clear All Data") + clear_btn.setStyleSheet(""" + QPushButton { + background-color: #f44336; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + } + """) + clear_btn.clicked.connect(self._clear_data) + data_layout.addWidget(clear_btn) + + # Retention + retention_layout = QHBoxLayout() + retention_layout.addWidget(QLabel("Data retention:")) + self.retention_combo = QComboBox() + self.retention_combo.addItems(["7 days", "30 days", "90 days", "Forever"]) + retention_layout.addWidget(self.retention_combo) + retention_layout.addStretch() + data_layout.addLayout(retention_layout) + + layout.addWidget(data_group) + layout.addStretch() + + return tab + + def _group_style(self): + """Get group box style.""" + return """ + QGroupBox { + color: rgba(255,255,255,200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + margin-top: 10px; + font-weight: bold; + font-size: 12px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + """ + + def _save_settings(self): + """Save all settings.""" + # General + self.settings.set('theme', self.theme_combo.currentText()) + self.settings.set('overlay_opacity', self.opacity_slider.value() / 100) + self.settings.set('auto_start', self.auto_start_cb.isChecked()) + self.settings.set('minimize_to_tray', self.minimize_cb.isChecked()) + self.settings.set('show_tooltips', self.tooltips_cb.isChecked()) + + # Hotkeys - new structure with dict values + for config_key, hotkey_data in self.hotkey_inputs.items(): + if isinstance(hotkey_data, dict): + input_field = hotkey_data['input'] + self.settings.set(config_key, input_field.text()) + else: + # Legacy format - direct QLineEdit reference + self.settings.set(config_key, hotkey_data.text()) + + print("Settings saved!") + + def _reset_settings(self): + """Reset to defaults.""" + self.settings.reset() + print("Settings reset to defaults!") + + def _export_data(self): + """Export all data.""" + from PyQt6.QtWidgets import QFileDialog + + filepath, _ = QFileDialog.getSaveFileName( + None, "Export EU-Utility Data", "eu_utility_backup.json", "JSON (*.json)" + ) + + if filepath: + import shutil + data_dir = Path("data") + if data_dir.exists(): + # Create export + import json + export_data = {} + for f in data_dir.glob("*.json"): + with open(f, 'r') as file: + export_data[f.stem] = json.load(file) + + with open(filepath, 'w') as file: + json.dump(export_data, file, indent=2) + + def _import_data(self): + """Import data.""" + pass + + def _clear_data(self): + """Clear all data.""" + pass + + def _toggle_plugin(self, plugin_id: str, enable: bool): + """Enable or disable a plugin with dependency handling.""" + if not hasattr(self.overlay, 'plugin_manager'): + return + + plugin_manager = self.overlay.plugin_manager + + if enable: + # Get dependencies that will be auto-enabled + deps_to_enable = self._get_missing_dependencies(plugin_id, plugin_manager) + + if deps_to_enable: + # Show confirmation dialog + from PyQt6.QtWidgets import QMessageBox + + dep_names = [pid.split('.')[-1].replace('_', ' ').title() for pid in deps_to_enable] + msg = f"Enabling this plugin will also enable:\n\n" + msg += "\n".join(f" • {name}" for name in dep_names) + msg += "\n\nContinue?" + + reply = QMessageBox.question( + None, "Enable Dependencies", msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply != QMessageBox.StandardButton.Yes: + # Uncheck the box + self.plugin_checkboxes[plugin_id].setChecked(False) + return + + success = plugin_manager.enable_plugin(plugin_id) + if success: + print(f"[Settings] Enabled plugin: {plugin_id}") + # Refresh UI to show auto-enabled plugins + self._refresh_plugin_list() + else: + print(f"[Settings] Failed to enable plugin: {plugin_id}") + else: + # Check if other enabled plugins depend on this one + dependents = self.plugin_dependents.get(plugin_id, []) + enabled_dependents = [d for d in dependents if plugin_manager.is_plugin_enabled(d)] + + if enabled_dependents: + # Show warning + from PyQt6.QtWidgets import QMessageBox + + dep_names = [pid.split('.')[-1].replace('_', ' ').title() for pid in enabled_dependents] + msg = f"Cannot disable: This plugin is required by:\n\n" + msg += "\n".join(f" • {name}" for name in dep_names) + msg += "\n\nDisable those plugins first." + + QMessageBox.warning(None, "Dependency Warning", msg) + + # Recheck the box + self.plugin_checkboxes[plugin_id].setChecked(True) + return + + success = plugin_manager.disable_plugin(plugin_id) + if success: + print(f"[Settings] Disabled plugin: {plugin_id}") + self._refresh_plugin_list() + + def _get_missing_dependencies(self, plugin_id: str, plugin_manager) -> list: + """Get list of dependencies that need to be enabled.""" + deps = self.plugin_deps.get(plugin_id, []) + missing = [] + + for dep_id in deps: + if not plugin_manager.is_plugin_enabled(dep_id): + missing.append(dep_id) + + return missing + + def _refresh_plugin_list(self): + """Refresh the plugin list UI.""" + if not hasattr(self.overlay, 'plugin_manager'): + return + + plugin_manager = self.overlay.plugin_manager + + for plugin_id, cb in self.plugin_checkboxes.items(): + is_enabled = plugin_manager.is_plugin_enabled(plugin_id) + is_auto_enabled = plugin_manager.is_auto_enabled(plugin_id) if hasattr(plugin_manager, 'is_auto_enabled') else False + + cb.setChecked(is_enabled) + + if is_auto_enabled: + cb.setEnabled(False) + # Update text to show auto status + plugin_class = plugin_manager.get_all_discovered_plugins().get(plugin_id) + if plugin_class: + cb.setText(f"{plugin_class.name} (auto)") + else: + cb.setEnabled(True) + plugin_class = plugin_manager.get_all_discovered_plugins().get(plugin_id) + if plugin_class: + cb.setText(plugin_class.name) + + def _generate_dependency_report(self) -> str: + """Generate HTML dependency report.""" + if not hasattr(self.overlay, 'plugin_manager'): + return "

No plugin manager available

" + + plugin_manager = self.overlay.plugin_manager + all_plugins = plugin_manager.get_all_discovered_plugins() + + html = [""] + html.append("

📋 Plugin Dependency Report

") + html.append("
") + + # Summary section + total = len(all_plugins) + enabled = sum(1 for pid in all_plugins if plugin_manager.is_plugin_enabled(pid)) + html.append(f"

Total Plugins: {total} | Enabled: {enabled}

") + html.append("
") + + # Plugins with dependencies + html.append("

🔗 Plugins with Dependencies

") + html.append("") + html.append("
") + + # Plugins required by others + html.append("

⚠️ Plugins Required by Others

") + html.append("") + html.append("
") + + # Dependency chain visualization + html.append("

🔄 Dependency Chains

") + html.append("") + html.append("") + + return "\n".join(html) + + def _get_dependency_chain(self, plugin_id: str, visited=None) -> list: + """Get the dependency chain for a plugin.""" + if visited is None: + visited = set() + + if plugin_id in visited: + return [plugin_id] # Circular dependency + + visited.add(plugin_id) + chain = [plugin_id] + + # Get what this plugin depends on + deps = self.plugin_deps.get(plugin_id, []) + for dep_id in deps: + if dep_id not in visited: + chain.extend(self._get_dependency_chain(dep_id, visited)) + + return chain + + def _enable_all_plugins(self): + """Enable all plugins with dependency resolution.""" + if not hasattr(self.overlay, 'plugin_manager'): + return + + plugin_manager = self.overlay.plugin_manager + all_plugins = plugin_manager.get_all_discovered_plugins() + + # Sort plugins so dependencies are enabled first + sorted_plugins = self._sort_plugins_by_dependencies(all_plugins) + + enabled_count = 0 + for plugin_id in sorted_plugins: + cb = self.plugin_checkboxes.get(plugin_id) + if cb: + cb.setChecked(True) + success = plugin_manager.enable_plugin(plugin_id) + if success: + enabled_count += 1 + + self._refresh_plugin_list() + print(f"[Settings] Enabled {enabled_count} plugins") + + def _sort_plugins_by_dependencies(self, all_plugins: dict) -> list: + """Sort plugins so dependencies come before dependents.""" + plugin_ids = list(all_plugins.keys()) + + # Build dependency graph + graph = {pid: set(self.plugin_deps.get(pid, [])) for pid in plugin_ids} + + # Topological sort + sorted_list = [] + visited = set() + temp_mark = set() + + def visit(pid): + if pid in temp_mark: + return # Circular dependency, skip + if pid in visited: + return + + temp_mark.add(pid) + for dep in graph.get(pid, set()): + if dep in graph: + visit(dep) + temp_mark.remove(pid) + visited.add(pid) + sorted_list.append(pid) + + for pid in plugin_ids: + visit(pid) + + return sorted_list + + def _disable_all_plugins(self): + """Disable all plugins in reverse dependency order.""" + if not hasattr(self.overlay, 'plugin_manager'): + return + + plugin_manager = self.overlay.plugin_manager + all_plugins = plugin_manager.get_all_discovered_plugins() + + # Sort so dependents are disabled first (reverse of enable order) + sorted_plugins = self._sort_plugins_by_dependencies(all_plugins) + sorted_plugins.reverse() + + disabled_count = 0 + for plugin_id in sorted_plugins: + cb = self.plugin_checkboxes.get(plugin_id) + if cb: + cb.setChecked(False) + success = plugin_manager.disable_plugin(plugin_id) + if success: + disabled_count += 1 + + self._refresh_plugin_list() + print(f"[Settings] Disabled {disabled_count} plugins") diff --git a/plugins/skill_scanner/__init__.py b/plugins/skill_scanner/__init__.py new file mode 100644 index 0000000..bf789dd --- /dev/null +++ b/plugins/skill_scanner/__init__.py @@ -0,0 +1,7 @@ +""" +Skill Scanner Plugin for EU-Utility +""" + +from .plugin import SkillScannerPlugin + +__all__ = ["SkillScannerPlugin"] diff --git a/plugins/skill_scanner/__pycache__/__init__.cpython-312.pyc b/plugins/skill_scanner/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..fcdbc3f Binary files /dev/null and b/plugins/skill_scanner/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/skill_scanner/__pycache__/plugin.cpython-312.pyc b/plugins/skill_scanner/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..04f1268 Binary files /dev/null and b/plugins/skill_scanner/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/skill_scanner/plugin.py b/plugins/skill_scanner/plugin.py new file mode 100644 index 0000000..0ba4b62 --- /dev/null +++ b/plugins/skill_scanner/plugin.py @@ -0,0 +1,1240 @@ +""" +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() diff --git a/plugins/spotify_controller/__init__.py b/plugins/spotify_controller/__init__.py new file mode 100644 index 0000000..d283b86 --- /dev/null +++ b/plugins/spotify_controller/__init__.py @@ -0,0 +1,7 @@ +""" +Spotify Controller Plugin for EU-Utility +""" + +from .plugin import SpotifyControllerPlugin + +__all__ = ["SpotifyControllerPlugin"] diff --git a/plugins/spotify_controller/__pycache__/__init__.cpython-312.pyc b/plugins/spotify_controller/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..3e51f90 Binary files /dev/null and b/plugins/spotify_controller/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/spotify_controller/__pycache__/plugin.cpython-312.pyc b/plugins/spotify_controller/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..e8888c3 Binary files /dev/null and b/plugins/spotify_controller/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/spotify_controller/plugin.py b/plugins/spotify_controller/plugin.py new file mode 100644 index 0000000..d935400 --- /dev/null +++ b/plugins/spotify_controller/plugin.py @@ -0,0 +1,473 @@ +""" +EU-Utility - Spotify Controller Plugin + +Control Spotify playback and display current track info. +""" + +import subprocess +import platform +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QSlider, QProgressBar +) +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal + +from plugins.base_plugin import BasePlugin + + +class SpotifyInfoThread(QThread): + """Background thread to fetch Spotify info.""" + info_ready = pyqtSignal(dict) + error = pyqtSignal(str) + + def __init__(self, system): + super().__init__() + self.system = system + + def run(self): + """Fetch Spotify info.""" + try: + if self.system == "Linux": + result = subprocess.run( + ['playerctl', '--player=spotify', 'metadata', '--format', + '{{title}}|{{artist}}|{{album}}|{{position}}|{{mpris:length}}|{{status}}'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + parts = result.stdout.strip().split('|') + if len(parts) >= 6: + self.info_ready.emit({ + 'title': parts[0] or 'Unknown', + 'artist': parts[1] or 'Unknown Artist', + 'album': parts[2] or '', + 'position': self._parse_time(parts[3]), + 'duration': self._parse_time(parts[4]), + 'is_playing': parts[5] == 'Playing' + }) + return + + elif self.system == "Darwin": + script = ''' + tell application "Spotify" + if player state is playing then + return (name of current track) & "|" & (artist of current track) & "|" & (album of current track) & "|" & (player position) & "|" & (duration of current track / 1000) & "|Playing" + else + return (name of current track) & "|" & (artist of current track) & "|" & (album of current track) & "|" & (player position) & "|" & (duration of current track / 1000) & "|Paused" + end if + end tell + ''' + result = subprocess.run(['osascript', '-e', script], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + parts = result.stdout.strip().split('|') + if len(parts) >= 6: + self.info_ready.emit({ + 'title': parts[0] or 'Unknown', + 'artist': parts[1] or 'Unknown Artist', + 'album': parts[2] or '', + 'position': float(parts[3]) if parts[3] else 0, + 'duration': float(parts[4]) if parts[4] else 0, + 'is_playing': parts[5] == 'Playing' + }) + return + + # Default/empty response + self.info_ready.emit({ + 'title': 'Not playing', + 'artist': '', + 'album': '', + 'position': 0, + 'duration': 0, + 'is_playing': False + }) + + except Exception as e: + self.error.emit(str(e)) + self.info_ready.emit({ + 'title': 'Not playing', + 'artist': '', + 'album': '', + 'position': 0, + 'duration': 0, + 'is_playing': False + }) + + def _parse_time(self, time_str): + """Parse time string to seconds.""" + try: + return int(time_str) / 1000000 + except: + return 0 + + +class SpotifyControllerPlugin(BasePlugin): + """Control Spotify playback and display current track.""" + + name = "Spotify" + version = "1.1.0" + author = "ImpulsiveFPS" + description = "Control Spotify and view current track info" + hotkey = "ctrl+shift+m" + + def initialize(self): + """Setup Spotify controller.""" + self.system = platform.system() + self.update_timer = None + self.info_thread = None + self.current_info = { + 'title': 'Not playing', + 'artist': '', + 'album': '', + 'position': 0, + 'duration': 0, + 'is_playing': False + } + + def get_ui(self): + """Create Spotify controller UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(12) + + # Title + title = QLabel("Spotify") + title.setStyleSheet("color: #1DB954; font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + # Album Art Placeholder + self.album_art = QLabel("💿") + self.album_art.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.album_art.setStyleSheet(""" + QLabel { + background-color: #282828; + border-radius: 8px; + font-size: 64px; + padding: 20px; + min-height: 120px; + } + """) + layout.addWidget(self.album_art) + + # Track info container + info_container = QWidget() + info_container.setStyleSheet(""" + QWidget { + background-color: #1a1a1a; + border-radius: 8px; + padding: 12px; + } + """) + info_layout = QVBoxLayout(info_container) + info_layout.setSpacing(4) + + # Track title + self.track_label = QLabel("Not playing") + self.track_label.setStyleSheet(""" + color: white; + font-size: 16px; + font-weight: bold; + """) + self.track_label.setWordWrap(True) + info_layout.addWidget(self.track_label) + + # Artist + self.artist_label = QLabel("") + self.artist_label.setStyleSheet(""" + color: #b3b3b3; + font-size: 13px; + """) + info_layout.addWidget(self.artist_label) + + # Album + self.album_label = QLabel("") + self.album_label.setStyleSheet(""" + color: #666; + font-size: 11px; + """) + info_layout.addWidget(self.album_label) + + layout.addWidget(info_container) + + # Time info + time_layout = QHBoxLayout() + self.position_label = QLabel("0:00") + self.position_label.setStyleSheet("color: #888; font-size: 11px;") + time_layout.addWidget(self.position_label) + + time_layout.addStretch() + + self.duration_label = QLabel("0:00") + self.duration_label.setStyleSheet("color: #888; font-size: 11px;") + time_layout.addWidget(self.duration_label) + + layout.addLayout(time_layout) + + # Progress bar + self.progress = QProgressBar() + self.progress.setRange(0, 100) + self.progress.setValue(0) + self.progress.setTextVisible(False) + self.progress.setStyleSheet(""" + QProgressBar { + background-color: #404040; + border: none; + height: 4px; + border-radius: 2px; + } + QProgressBar::chunk { + background-color: #1DB954; + border-radius: 2px; + } + """) + layout.addWidget(self.progress) + + # Control buttons + btn_layout = QHBoxLayout() + btn_layout.setSpacing(15) + btn_layout.addStretch() + + # Previous + prev_btn = QPushButton("⏮") + prev_btn.setFixedSize(50, 50) + prev_btn.setStyleSheet(self._get_button_style("#404040")) + prev_btn.clicked.connect(self._previous_track) + btn_layout.addWidget(prev_btn) + + # Play/Pause + self.play_btn = QPushButton("▶") + self.play_btn.setFixedSize(60, 60) + self.play_btn.setStyleSheet(self._get_play_button_style()) + self.play_btn.clicked.connect(self._toggle_playback) + btn_layout.addWidget(self.play_btn) + + # Next + next_btn = QPushButton("⏭") + next_btn.setFixedSize(50, 50) + next_btn.setStyleSheet(self._get_button_style("#404040")) + next_btn.clicked.connect(self._next_track) + btn_layout.addWidget(next_btn) + + btn_layout.addStretch() + layout.addLayout(btn_layout) + + # Volume + volume_layout = QHBoxLayout() + volume_layout.addWidget(QLabel("🔈")) + + self.volume_slider = QSlider(Qt.Orientation.Horizontal) + self.volume_slider.setRange(0, 100) + self.volume_slider.setValue(50) + self.volume_slider.setStyleSheet(""" + QSlider::groove:horizontal { + background: #404040; + height: 4px; + border-radius: 2px; + } + QSlider::handle:horizontal { + background: #fff; + width: 12px; + margin: -4px 0; + border-radius: 6px; + } + QSlider::sub-page:horizontal { + background: #1DB954; + border-radius: 2px; + } + """) + self.volume_slider.valueChanged.connect(self._set_volume) + volume_layout.addWidget(self.volume_slider) + + volume_layout.addWidget(QLabel("🔊")) + layout.addLayout(volume_layout) + + # Status + self.status_label = QLabel("Click play to control Spotify") + self.status_label.setStyleSheet("color: #666; font-size: 10px;") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.status_label) + + layout.addStretch() + + # Start update timer + self._start_timer() + + return widget + + def _get_button_style(self, color): + """Get button stylesheet.""" + return f""" + QPushButton {{ + background-color: {color}; + color: white; + font-size: 18px; + border: none; + border-radius: 25px; + }} + QPushButton:hover {{ + background-color: #505050; + }} + QPushButton:pressed {{ + background-color: #303030; + }} + """ + + def _get_play_button_style(self): + """Get play button style (green).""" + return """ + QPushButton { + background-color: #1DB954; + color: white; + font-size: 22px; + border: none; + border-radius: 30px; + } + QPushButton:hover { + background-color: #1ed760; + } + QPushButton:pressed { + background-color: #1aa34a; + } + """ + + def _start_timer(self): + """Start status update timer.""" + self.update_timer = QTimer() + self.update_timer.timeout.connect(self._fetch_spotify_info) + self.update_timer.start(1000) + + def _fetch_spotify_info(self): + """Fetch Spotify info in background.""" + if self.info_thread and self.info_thread.isRunning(): + return + + self.info_thread = SpotifyInfoThread(self.system) + self.info_thread.info_ready.connect(self._update_ui) + self.info_thread.start() + + def _update_ui(self, info): + """Update UI with Spotify info.""" + self.current_info = info + + # Update track info + self.track_label.setText(info.get('title', 'Unknown')) + self.artist_label.setText(info.get('artist', '')) + self.album_label.setText(info.get('album', '')) + + # Update play button + is_playing = info.get('is_playing', False) + self.play_btn.setText("⏸" if is_playing else "▶") + + # Update time + position = info.get('position', 0) + duration = info.get('duration', 0) + + self.position_label.setText(self._format_time(position)) + self.duration_label.setText(self._format_time(duration)) + + # Update progress bar + if duration > 0: + progress = int((position / duration) * 100) + self.progress.setValue(progress) + else: + self.progress.setValue(0) + + def _format_time(self, seconds): + """Format seconds to mm:ss.""" + try: + minutes = int(seconds) // 60 + secs = int(seconds) % 60 + return f"{minutes}:{secs:02d}" + except: + return "0:00" + + def _send_media_key(self, key): + """Send media key press to system.""" + try: + if self.system == "Windows": + import ctypes + key_codes = { + 'play': 0xB3, + 'next': 0xB0, + 'prev': 0xB1, + } + if key in key_codes: + ctypes.windll.user32.keybd_event(key_codes[key], 0, 0, 0) + ctypes.windll.user32.keybd_event(key_codes[key], 0, 2, 0) + return True + + elif self.system == "Linux": + cmd_map = { + 'play': ['playerctl', '--player=spotify', 'play-pause'], + 'next': ['playerctl', '--player=spotify', 'next'], + 'prev': ['playerctl', '--player=spotify', 'previous'], + } + if key in cmd_map: + subprocess.run(cmd_map[key], capture_output=True) + return True + + elif self.system == "Darwin": + cmd_map = { + 'play': ['osascript', '-e', 'tell application "Spotify" to playpause'], + 'next': ['osascript', '-e', 'tell application "Spotify" to next track'], + 'prev': ['osascript', '-e', 'tell application "Spotify" to previous track'], + } + if key in cmd_map: + subprocess.run(cmd_map[key], capture_output=True) + return True + + except Exception as e: + print(f"Error sending media key: {e}") + + return False + + def _toggle_playback(self): + """Toggle play/pause.""" + if self._send_media_key('play'): + self.current_info['is_playing'] = not self.current_info.get('is_playing', False) + self.play_btn.setText("⏸" if self.current_info['is_playing'] else "▶") + self.status_label.setText("Command sent to Spotify") + else: + self.status_label.setText("❌ Could not control Spotify") + + def _next_track(self): + """Next track.""" + if self._send_media_key('next'): + self.status_label.setText("⏭ Next track") + else: + self.status_label.setText("❌ Could not skip") + + def _previous_track(self): + """Previous track.""" + if self._send_media_key('prev'): + self.status_label.setText("⏮ Previous track") + else: + self.status_label.setText("❌ Could not go back") + + def _set_volume(self, value): + """Set volume (0-100).""" + try: + if self.system == "Linux": + subprocess.run(['playerctl', '--player=spotify', 'volume', str(value / 100)], capture_output=True) + except: + pass + + def on_hotkey(self): + """Toggle play/pause with hotkey.""" + self._toggle_playback() + + def on_hide(self): + """Stop timer when overlay hidden.""" + if self.update_timer: + self.update_timer.stop() + + def on_show(self): + """Restart timer when overlay shown.""" + if self.update_timer: + self.update_timer.start() + self._fetch_spotify_info() + + def shutdown(self): + """Cleanup.""" + if self.update_timer: + self.update_timer.stop() + if self.info_thread and self.info_thread.isRunning(): + self.info_thread.wait() diff --git a/plugins/tp_runner/__init__.py b/plugins/tp_runner/__init__.py new file mode 100644 index 0000000..25d81e4 --- /dev/null +++ b/plugins/tp_runner/__init__.py @@ -0,0 +1,7 @@ +""" +TP Runner Plugin +""" + +from .plugin import TPRunnerPlugin + +__all__ = ["TPRunnerPlugin"] diff --git a/plugins/tp_runner/__pycache__/__init__.cpython-312.pyc b/plugins/tp_runner/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6fa2908 Binary files /dev/null and b/plugins/tp_runner/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/tp_runner/__pycache__/plugin.cpython-312.pyc b/plugins/tp_runner/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..ba42533 Binary files /dev/null and b/plugins/tp_runner/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/tp_runner/plugin.py b/plugins/tp_runner/plugin.py new file mode 100644 index 0000000..1df3333 --- /dev/null +++ b/plugins/tp_runner/plugin.py @@ -0,0 +1,219 @@ +""" +EU-Utility - TP Runner Plugin + +Track teleporter locations and plan routes. +""" + +import json +from pathlib import Path + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QComboBox, QTreeWidget, QTreeWidgetItem, + QLineEdit, QFrame +) +from PyQt6.QtCore import Qt + +from plugins.base_plugin import BasePlugin + + +class TPRunnerPlugin(BasePlugin): + """Track TP locations and plan routes.""" + + name = "TP Runner" + version = "1.0.0" + author = "ImpulsiveFPS" + description = "Teleporter locations and route planner" + hotkey = "ctrl+shift+p" # P for Port + + # Arkadia TPs + ARKADIA_TPS = [ + "Arkadia City", "Arkadia City Outskirts", "8 Coins Creek", + "Celeste Harbour", "Celeste Outpost North", "Celeste Outpost South", + "Celeste Quarry", "Cycadia", "Dauntless Dock", "East Scythe", + "Genesis", "Hadesheim", "Hadesheim Ashland", "Hadesheim Pass", + "Hadesheim Valley", "Hellfire Hills", "Hero's Landing", + "Horror-Filled Hallows", "Jagged Coast", "Jungle Camp", + "Khorum Coast", "Khorum Highway", "Kronus", "Lava Camp", + "Lighthouse", "Living Graveyard", "Mountaintop", "Neo-Shanghai", + "North Scythe", "Nusul Fields", "Oily Business", "Perseus", + "Pilgrim's Landing", "Poseidon West", "Red Sands", + "Releks Hills", "Rest Stop", "Ripper Snapper", "Sacred Cove", + "Sentinel Hill", "Shady Ridge", "Sisyphus", "South Scythe", + "Spiral Mountain", "Stormbird Landing", "Sundari", "Tides", + "Traveller's Landing", "Victorious", "Vikings", "West Scythe", + "Wild Banks", "Wolf's Ridge", + ] + + def initialize(self): + """Setup TP runner.""" + self.data_file = Path("data/tp_runner.json") + self.data_file.parent.mkdir(parents=True, exist_ok=True) + + self.unlocked_tps = set() + self.favorite_routes = [] + + self._load_data() + + def _load_data(self): + """Load TP data.""" + if self.data_file.exists(): + try: + with open(self.data_file, 'r') as f: + data = json.load(f) + self.unlocked_tps = set(data.get('unlocked', [])) + self.favorite_routes = data.get('routes', []) + except: + pass + + def _save_data(self): + """Save TP data.""" + with open(self.data_file, 'w') as f: + json.dump({ + 'unlocked': list(self.unlocked_tps), + 'routes': self.favorite_routes + }, f, indent=2) + + def get_ui(self): + """Create TP runner UI.""" + widget = QWidget() + widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(widget) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("🚀 TP Runner") + title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + # Progress + unlocked = len(self.unlocked_tps) + total = len(self.ARKADIA_TPS) + progress = QLabel(f"Unlocked: {unlocked}/{total} ({unlocked/total*100:.1f}%)") + progress.setStyleSheet("color: #4caf50;") + layout.addWidget(progress) + + # Route planner + planner = QFrame() + planner.setStyleSheet(""" + QFrame { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + """) + planner_layout = QVBoxLayout(planner) + + # From/To + fromto = QHBoxLayout() + + self.from_combo = QComboBox() + self.from_combo.addItems(self.ARKADIA_TPS) + self.from_combo.setStyleSheet(""" + QComboBox { + background-color: rgba(20, 25, 35, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + padding: 5px; + } + """) + fromto.addWidget(QLabel("From:")) + fromto.addWidget(self.from_combo) + + self.to_combo = QComboBox() + self.to_combo.addItems(self.ARKADIA_TPS) + self.to_combo.setStyleSheet(self.from_combo.styleSheet()) + fromto.addWidget(QLabel("To:")) + fromto.addWidget(self.to_combo) + + planner_layout.addLayout(fromto) + + # Route button + route_btn = QPushButton("Find Route") + route_btn.setStyleSheet(""" + QPushButton { + background-color: #ff8c42; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + route_btn.clicked.connect(self._find_route) + planner_layout.addWidget(route_btn) + + layout.addWidget(planner) + + # TP List + self.tp_tree = QTreeWidget() + self.tp_tree.setHeaderLabels(["Teleporter", "Status"]) + self.tp_tree.setStyleSheet(""" + QTreeWidget { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + } + QHeaderView::section { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,180); + padding: 8px; + font-weight: bold; + font-size: 11px; + } + """) + + # Populate list + for tp in sorted(self.ARKADIA_TPS): + item = QTreeWidgetItem() + item.setText(0, tp) + if tp in self.unlocked_tps: + item.setText(1, "Unlocked") + item.setForeground(1, Qt.GlobalColor.green) + else: + item.setText(1, "Locked") + item.setForeground(1, Qt.GlobalColor.gray) + self.tp_tree.addTopLevelItem(item) + + layout.addWidget(self.tp_tree) + + # Mark as unlocked button + unlock_btn = QPushButton("Mark Unlocked") + unlock_btn.setStyleSheet(""" + QPushButton { + background-color: #4caf50; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + font-weight: bold; + } + """) + unlock_btn.clicked.connect(self._mark_unlocked) + layout.addWidget(unlock_btn) + + layout.addStretch() + return widget + + def _find_route(self): + """Find route between TPs.""" + from_tp = self.from_combo.currentText() + to_tp = self.to_combo.currentText() + + if from_tp == to_tp: + return + + # Simple distance estimation (would use actual coordinates) + # For now, just show direct route + + def _mark_unlocked(self): + """Mark selected TP as unlocked.""" + item = self.tp_tree.currentItem() + if item: + tp_name = item.text(0) + self.unlocked_tps.add(tp_name) + item.setText(1, "Unlocked") + item.setForeground(1, Qt.GlobalColor.green) + self._save_data() diff --git a/plugins/universal_search/__init__.py b/plugins/universal_search/__init__.py new file mode 100644 index 0000000..44d5b53 --- /dev/null +++ b/plugins/universal_search/__init__.py @@ -0,0 +1,7 @@ +""" +Universal Search Plugin for EU-Utility +""" + +from .plugin import UniversalSearchPlugin + +__all__ = ["UniversalSearchPlugin"] diff --git a/plugins/universal_search/__pycache__/__init__.cpython-312.pyc b/plugins/universal_search/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8befb4f Binary files /dev/null and b/plugins/universal_search/__pycache__/__init__.cpython-312.pyc differ diff --git a/plugins/universal_search/__pycache__/plugin.cpython-312.pyc b/plugins/universal_search/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..cafa0f9 Binary files /dev/null and b/plugins/universal_search/__pycache__/plugin.cpython-312.pyc differ diff --git a/plugins/universal_search/plugin.py b/plugins/universal_search/plugin.py new file mode 100644 index 0000000..31f0f0c --- /dev/null +++ b/plugins/universal_search/plugin.py @@ -0,0 +1,600 @@ +""" +EU-Utility - Universal Search Plugin + +Search across all Entropia Nexus entities - items, mobs, locations, blueprints, skills, etc. +""" + +import json +import webbrowser +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLineEdit, QPushButton, QLabel, QComboBox, + QTableWidget, QTableWidgetItem, QHeaderView, + QTabWidget, QStackedWidget, QFrame +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +from plugins.base_plugin import BasePlugin + + +class NexusEntityAPI: + """Client for Entropia Nexus Entity API.""" + + BASE_URL = "https://api.entropianexus.com" + + # Entity type to API endpoint mapping + ENDPOINTS = { + "Items": "/items", + "Weapons": "/weapons", + "Armors": "/armors", + "Blueprints": "/blueprints", + "Mobs": "/mobs", + "Locations": "/locations", + "Skills": "/skills", + "Materials": "/materials", + "Enhancers": "/enhancers", + "Medical Tools": "/medicaltools", + "Finders": "/finders", + "Excavators": "/excavators", + "Refiners": "/refiners", + "Vehicles": "/vehicles", + "Pets": "/pets", + "Decorations": "/decorations", + "Furniture": "/furniture", + "Storage": "/storagecontainers", + "Strongboxes": "/strongboxes", + "Teleporters": "/teleporters", + "Shops": "/shops", + "Vendors": "/vendors", + "Planets": "/planets", + "Areas": "/areas", + } + + @classmethod + def search_entities(cls, entity_type, query, limit=50, http_get_func=None): + """Search for entities of a specific type.""" + try: + endpoint = cls.ENDPOINTS.get(entity_type, "/items") + + # Build URL with query params + params = {'q': query, 'limit': limit, 'fuzzy': 'true'} + query_string = '&'.join(f"{k}={v}" for k, v in params.items()) + url = f"{cls.BASE_URL}{endpoint}?{query_string}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=300, # 5 minute cache + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + response = {'json': json.loads(resp.read().decode('utf-8'))} + + data = response.get('json') if response else None + return data if isinstance(data, list) else [] + + except Exception as e: + print(f"API Error ({entity_type}): {e}") + return [] + + @classmethod + def universal_search(cls, query, limit=30, http_get_func=None): + """Universal search across all entity types.""" + try: + params = {'query': query, 'limit': limit, 'fuzzy': 'true'} + query_string = '&'.join(f"{k}={v}" for k, v in params.items()) + url = f"{cls.BASE_URL}/search?{query_string}" + + if http_get_func: + response = http_get_func( + url, + cache_ttl=300, # 5 minute cache + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + else: + # Fallback for standalone usage + import urllib.request + req = urllib.request.Request( + url, + headers={'Accept': 'application/json', 'User-Agent': 'EU-Utility/1.0'} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + response = {'json': json.loads(resp.read().decode('utf-8'))} + + data = response.get('json') if response else None + return data if isinstance(data, list) else [] + + except Exception as e: + print(f"Universal Search Error: {e}") + return [] + + @classmethod + def get_entity_url(cls, entity_type, entity_id_or_name): + """Get the web URL for an entity.""" + web_base = "https://www.entropianexus.com" + + # Map to web paths + web_paths = { + "Items": "items", + "Weapons": "items", + "Armors": "items", + "Blueprints": "blueprints", + "Mobs": "mobs", + "Locations": "locations", + "Skills": "skills", + "Materials": "items", + "Enhancers": "items", + "Medical Tools": "items", + "Finders": "items", + "Excavators": "items", + "Refiners": "items", + "Vehicles": "items", + "Pets": "items", + "Decorations": "items", + "Furniture": "items", + "Storage": "items", + "Strongboxes": "items", + "Teleporters": "locations", + "Shops": "locations", + "Vendors": "locations", + "Planets": "locations", + "Areas": "locations", + } + + path = web_paths.get(entity_type, "items") + return f"{web_base}/{path}/{entity_id_or_name}" + + +class UniversalSearchThread(QThread): + """Background thread for API searches.""" + results_ready = pyqtSignal(list, str) + error_occurred = pyqtSignal(str) + + def __init__(self, query, entity_type, universal=False, http_get_func=None): + super().__init__() + self.query = query + self.entity_type = entity_type + self.universal = universal + self.http_get_func = http_get_func + + def run(self): + """Perform API search.""" + try: + if self.universal: + results = NexusEntityAPI.universal_search(self.query, http_get_func=self.http_get_func) + else: + results = NexusEntityAPI.search_entities(self.entity_type, self.query, http_get_func=self.http_get_func) + + self.results_ready.emit(results, self.entity_type) + + except Exception as e: + self.error_occurred.emit(str(e)) + + +class UniversalSearchPlugin(BasePlugin): + """Universal search across all Nexus entities.""" + + name = "Universal Search" + version = "2.0.0" + author = "ImpulsiveFPS" + description = "Search items, mobs, locations, blueprints, skills, and more" + hotkey = "ctrl+shift+f" # F for Find + + def initialize(self): + """Setup the plugin.""" + self.search_thread = None + self.current_results = [] + self.current_entity_type = "Universal" + + def get_ui(self): + """Create plugin UI.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setSpacing(10) + + # Title - NO EMOJI + title = QLabel("Universal Search") + title.setStyleSheet("color: #4a9eff; font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + # Search mode selector + mode_layout = QHBoxLayout() + mode_layout.addWidget(QLabel("Mode:")) + + self.search_mode = QComboBox() + self.search_mode.addItem("Universal (All Types)", "Universal") + self.search_mode.addItem("──────────────────", "separator") + + # Add all entity types + entity_types = [ + "Items", + "Weapons", + "Armors", + "Blueprints", + "Mobs", + "Locations", + "Skills", + "Materials", + "Enhancers", + "Medical Tools", + "Finders", + "Excavators", + "Refiners", + "Vehicles", + "Pets", + "Decorations", + "Furniture", + "Storage", + "Strongboxes", + "Teleporters", + "Shops", + "Vendors", + "Planets", + "Areas", + ] + + for etype in entity_types: + self.search_mode.addItem(f" {etype}", etype) + + self.search_mode.setStyleSheet(""" + QComboBox { + background-color: #444; + color: white; + padding: 8px; + border-radius: 4px; + min-width: 200px; + } + QComboBox::drop-down { + border: none; + } + """) + self.search_mode.currentIndexChanged.connect(self._on_mode_changed) + mode_layout.addWidget(self.search_mode) + mode_layout.addStretch() + + layout.addLayout(mode_layout) + + # Search bar + search_layout = QHBoxLayout() + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search for anything... (e.g., 'ArMatrix', 'Argonaut', 'Calypso')") + self.search_input.setStyleSheet(""" + QLineEdit { + background-color: #333; + color: white; + padding: 10px; + border: 2px solid #555; + border-radius: 4px; + font-size: 14px; + } + QLineEdit:focus { + border-color: #4a9eff; + } + """) + self.search_input.returnPressed.connect(self._do_search) + search_layout.addWidget(self.search_input, 1) + + search_btn = QPushButton("Search") + search_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + font-weight: bold; + font-size: 13px; + } + QPushButton:hover { + background-color: #5aafff; + } + """) + search_btn.clicked.connect(self._do_search) + search_layout.addWidget(search_btn) + + layout.addLayout(search_layout) + + # Status + self.status_label = QLabel("Ready to search") + self.status_label.setStyleSheet("color: #666; font-size: 11px;") + layout.addWidget(self.status_label) + + # Results table + self.results_table = QTableWidget() + self.results_table.setColumnCount(4) + self.results_table.setHorizontalHeaderLabels(["Name", "Type", "Details", "ID"]) + self.results_table.horizontalHeader().setStretchLastSection(False) + self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + self.results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) + self.results_table.setColumnWidth(1, 120) + self.results_table.setColumnWidth(3, 60) + self.results_table.verticalHeader().setVisible(False) + self.results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.results_table.setStyleSheet(""" + QTableWidget { + background-color: #2a2a2a; + color: white; + border: 1px solid #444; + border-radius: 4px; + gridline-color: #333; + } + QTableWidget::item { + padding: 10px; + border-bottom: 1px solid #333; + } + QTableWidget::item:selected { + background-color: #4a9eff; + } + QTableWidget::item:hover { + background-color: #3a3a3a; + } + QHeaderView::section { + background-color: #333; + color: #aaa; + padding: 10px; + border: none; + font-weight: bold; + } + """) + self.results_table.cellDoubleClicked.connect(self._on_item_double_clicked) + self.results_table.setMaximumHeight(350) + self.results_table.setMinimumHeight(200) + layout.addWidget(self.results_table) + + # Action buttons + action_layout = QHBoxLayout() + + self.open_btn = QPushButton("Open Selected") + self.open_btn.setEnabled(False) + self.open_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + } + QPushButton:hover { + background-color: #5aafff; + } + QPushButton:disabled { + background-color: #444; + color: #666; + } + """) + self.open_btn.clicked.connect(self._open_selected) + action_layout.addWidget(self.open_btn) + + action_layout.addStretch() + + # Quick category buttons + quick_label = QLabel("Quick:") + quick_label.setStyleSheet("color: #666;") + action_layout.addWidget(quick_label) + + for category in ["Items", "Mobs", "Blueprints", "Locations"]: + btn = QPushButton(category) + btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #4a9eff; + border: 1px solid #4a9eff; + padding: 5px 10px; + border-radius: 3px; + } + QPushButton:hover { + background-color: #4a9eff; + color: white; + } + """) + btn.clicked.connect(lambda checked, c=category: self._quick_search(c)) + action_layout.addWidget(btn) + + layout.addLayout(action_layout) + + # Tips + tips = QLabel("Tip: Double-click result to open on Nexus website") + tips.setStyleSheet("color: #555; font-size: 10px;") + layout.addWidget(tips) + + layout.addStretch() + + return widget + + def _on_mode_changed(self): + """Handle search mode change.""" + data = self.search_mode.currentData() + if data == "separator": + # Reset to previous valid selection + self.search_mode.setCurrentIndex(0) + + def _do_search(self): + """Perform search.""" + query = self.search_input.text().strip() + if len(query) < 2: + self.status_label.setText("Enter at least 2 characters") + return + + entity_type = self.search_mode.currentData() + if entity_type == "separator": + entity_type = "Universal" + + self.current_entity_type = entity_type + universal = (entity_type == "Universal") + + # Clear previous results + self.results_table.setRowCount(0) + self.current_results = [] + self.open_btn.setEnabled(False) + self.status_label.setText(f"Searching for '{query}'...") + + # Start search thread with http_get function + self.search_thread = UniversalSearchThread( + query, entity_type, universal, + http_get_func=self.http_get + ) + self.search_thread.results_ready.connect(self._on_results) + self.search_thread.error_occurred.connect(self._on_error) + self.search_thread.start() + + def _quick_search(self, category): + """Quick search for a specific category.""" + # Set the category + index = self.search_mode.findData(category) + if index >= 0: + self.search_mode.setCurrentIndex(index) + + # If there's text in the search box, search immediately + if self.search_input.text().strip(): + self._do_search() + else: + self.search_input.setFocus() + self.status_label.setText(f"Selected: {category} - Enter search term") + + def _on_results(self, results, entity_type): + """Handle search results.""" + self.current_results = results + + if not results: + self.status_label.setText("No results found") + return + + # Populate table + self.results_table.setRowCount(len(results)) + + for row, item in enumerate(results): + # Extract data based on available fields + name = item.get('name', item.get('Name', 'Unknown')) + item_id = str(item.get('id', item.get('Id', ''))) + + # Determine type + if 'type' in item: + item_type = item['type'] + elif entity_type != "Universal": + item_type = entity_type + else: + # Try to infer from other fields + item_type = self._infer_type(item) + + # Build details string + details = self._build_details(item, item_type) + + # Set table items + self.results_table.setItem(row, 0, QTableWidgetItem(name)) + self.results_table.setItem(row, 1, QTableWidgetItem(item_type)) + self.results_table.setItem(row, 2, QTableWidgetItem(details)) + self.results_table.setItem(row, 3, QTableWidgetItem(item_id)) + + self.open_btn.setEnabled(True) + self.status_label.setText(f"Found {len(results)} results") + + def _infer_type(self, item): + """Infer entity type from item fields.""" + if 'damage' in item or 'range' in item: + return "Weapon" + elif 'protection' in item or 'durability' in item: + return "Armor" + elif 'hitpoints' in item: + return "Mob" + elif 'x' in item and 'y' in item: + return "Location" + elif 'qr' in item or 'click' in item: + return "Blueprint" + elif 'category' in item: + return item['category'] + else: + return "Item" + + def _build_details(self, item, item_type): + """Build details string based on item type.""" + details = [] + + if item_type in ["Weapon", "Weapons"]: + if 'damage' in item: + details.append(f"Dmg: {item['damage']}") + if 'range' in item: + details.append(f"Range: {item['range']}m") + if 'attacks' in item: + details.append(f"{item['attacks']} attacks") + + elif item_type in ["Armor", "Armors"]: + if 'protection' in item: + details.append(f"Prot: {item['protection']}") + if 'durability' in item: + details.append(f"Dur: {item['durability']}") + + elif item_type in ["Mob", "Mobs"]: + if 'hitpoints' in item: + details.append(f"HP: {item['hitpoints']}") + if 'damage' in item: + details.append(f"Dmg: {item['damage']}") + if 'threat' in item: + details.append(f"Threat: {item['threat']}") + + elif item_type in ["Blueprint", "Blueprints"]: + if 'qr' in item: + details.append(f"QR: {item['qr']}") + if 'click' in item: + details.append(f"Clicks: {item['click']}") + + elif item_type in ["Location", "Locations", "Teleporter", "Shop"]: + if 'planet' in item: + details.append(item['planet']) + if 'x' in item and 'y' in item: + details.append(f"[{item['x']}, {item['y']}]") + + elif item_type in ["Skill", "Skills"]: + if 'category' in item: + details.append(item['category']) + + # Add any other interesting fields + if 'level' in item: + details.append(f"Lvl: {item['level']}") + if 'weight' in item: + details.append(f"{item['weight']}kg") + + return " | ".join(details) if details else "" + + def _on_error(self, error): + """Handle search error.""" + self.status_label.setText(f"Error: {error}") + + def _on_item_double_clicked(self, row, column): + """Handle item double-click.""" + self._open_result(row) + + def _open_selected(self): + """Open selected result.""" + selected = self.results_table.selectedItems() + if selected: + row = selected[0].row() + self._open_result(row) + + def _open_result(self, row): + """Open result in browser.""" + if row < len(self.current_results): + item = self.current_results[row] + entity_id = item.get('id', item.get('Id', '')) + entity_name = item.get('name', item.get('Name', '')) + + # Use name for URL if available, otherwise ID + url_param = entity_name if entity_name else str(entity_id) + url = NexusEntityAPI.get_entity_url(self.current_entity_type, url_param) + + webbrowser.open(url) + + def on_hotkey(self): + """Focus search when hotkey pressed.""" + if hasattr(self, 'search_input'): + self.search_input.setFocus() + self.search_input.selectAll()