From 2f94cf85fc13ecfea0f783523d24a06c0c06b10b Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Sun, 15 Feb 2026 01:40:08 +0000 Subject: [PATCH] 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. --- README.md | 146 ++ manifest.json | 344 +++++ plugins/__init__.py | 0 plugins/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 118 bytes .../__pycache__/base_plugin.cpython-312.pyc | Bin 0 -> 45123 bytes plugins/analytics/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 203 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 26052 bytes plugins/analytics/plugin.py | 525 +++++++ plugins/auction_tracker/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 260 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 12513 bytes plugins/auction_tracker/plugin.py | 261 ++++ plugins/auto_screenshot/__init__.py | 10 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 360 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 40279 bytes plugins/auto_screenshot/plugin.py | 735 ++++++++++ plugins/auto_updater/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 208 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 25950 bytes plugins/auto_updater/plugin.py | 481 +++++++ plugins/base_plugin.py | 1193 ++++++++++++++++ plugins/calculator/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 261 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 15980 bytes plugins/calculator/plugin.py | 386 +++++ plugins/chat_logger/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 248 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 12670 bytes plugins/chat_logger/plugin.py | 285 ++++ plugins/codex_tracker/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 254 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 11671 bytes plugins/codex_tracker/plugin.py | 218 +++ plugins/crafting_calc/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 266 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 9809 bytes plugins/crafting_calc/plugin.py | 219 +++ plugins/dashboard/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 243 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 15962 bytes plugins/dashboard/plugin.py | 326 +++++ plugins/discord_presence/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 216 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 10267 bytes plugins/discord_presence/plugin.py | 217 +++ plugins/dpp_calculator/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 257 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 10218 bytes plugins/dpp_calculator/plugin.py | 230 +++ plugins/enhancer_calc/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 266 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 7235 bytes plugins/enhancer_calc/plugin.py | 160 +++ plugins/event_bus_example/__init__.py | 4 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 260 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 10594 bytes plugins/event_bus_example/plugin.py | 211 +++ plugins/game_reader/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 263 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 12020 bytes plugins/game_reader/plugin.py | 261 ++++ plugins/game_reader_test/__init__.py | 2 + .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 53498 bytes plugins/game_reader_test/plugin.py | 1197 ++++++++++++++++ plugins/global_tracker/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 257 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 12794 bytes plugins/global_tracker/plugin.py | 257 ++++ plugins/import_export/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 210 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 16792 bytes plugins/import_export/plugin.py | 333 +++++ plugins/inventory_manager/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 266 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 11293 bytes plugins/inventory_manager/plugin.py | 219 +++ plugins/log_parser_test/__init__.py | 2 + plugins/log_parser_test/plugin.py | 384 +++++ plugins/loot_tracker/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 251 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 10327 bytes plugins/loot_tracker/plugin.py | 224 +++ plugins/mining_helper/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 254 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 11484 bytes plugins/mining_helper/plugin.py | 273 ++++ plugins/mission_tracker/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 260 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 14393 bytes plugins/mission_tracker/plugin.py | 315 +++++ plugins/nexus_search/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 266 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 20301 bytes plugins/nexus_search/plugin.py | 442 ++++++ plugins/plugin_store_ui/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 259 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 11821 bytes plugins/plugin_store_ui/plugin.py | 273 ++++ plugins/price_alerts/__init__.py | 10 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 343 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 35306 bytes plugins/price_alerts/plugin.py | 693 +++++++++ plugins/profession_scanner/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 269 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 12733 bytes plugins/profession_scanner/plugin.py | 247 ++++ plugins/session_exporter/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 340 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 31418 bytes plugins/session_exporter/plugin.py | 643 +++++++++ plugins/settings/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 240 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 52393 bytes plugins/settings/plugin.py | 1174 ++++++++++++++++ plugins/skill_scanner/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 269 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 59667 bytes plugins/skill_scanner/plugin.py | 1240 +++++++++++++++++ plugins/spotify_controller/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 284 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 21157 bytes plugins/spotify_controller/plugin.py | 473 +++++++ plugins/tp_runner/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 242 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 9885 bytes plugins/tp_runner/plugin.py | 219 +++ plugins/universal_search/__init__.py | 7 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 278 bytes .../__pycache__/plugin.cpython-312.pyc | Bin 0 -> 26627 bytes plugins/universal_search/plugin.py | 600 ++++++++ 131 files changed, 15607 insertions(+) create mode 100644 README.md create mode 100644 manifest.json create mode 100644 plugins/__init__.py create mode 100644 plugins/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/__pycache__/base_plugin.cpython-312.pyc create mode 100644 plugins/analytics/__init__.py create mode 100644 plugins/analytics/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/analytics/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/analytics/plugin.py create mode 100644 plugins/auction_tracker/__init__.py create mode 100644 plugins/auction_tracker/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/auction_tracker/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/auction_tracker/plugin.py create mode 100644 plugins/auto_screenshot/__init__.py create mode 100644 plugins/auto_screenshot/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/auto_screenshot/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/auto_screenshot/plugin.py create mode 100644 plugins/auto_updater/__init__.py create mode 100644 plugins/auto_updater/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/auto_updater/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/auto_updater/plugin.py create mode 100644 plugins/base_plugin.py create mode 100644 plugins/calculator/__init__.py create mode 100644 plugins/calculator/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/calculator/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/calculator/plugin.py create mode 100644 plugins/chat_logger/__init__.py create mode 100644 plugins/chat_logger/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/chat_logger/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/chat_logger/plugin.py create mode 100644 plugins/codex_tracker/__init__.py create mode 100644 plugins/codex_tracker/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/codex_tracker/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/codex_tracker/plugin.py create mode 100644 plugins/crafting_calc/__init__.py create mode 100644 plugins/crafting_calc/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/crafting_calc/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/crafting_calc/plugin.py create mode 100644 plugins/dashboard/__init__.py create mode 100644 plugins/dashboard/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/dashboard/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/dashboard/plugin.py create mode 100644 plugins/discord_presence/__init__.py create mode 100644 plugins/discord_presence/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/discord_presence/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/discord_presence/plugin.py create mode 100644 plugins/dpp_calculator/__init__.py create mode 100644 plugins/dpp_calculator/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/dpp_calculator/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/dpp_calculator/plugin.py create mode 100644 plugins/enhancer_calc/__init__.py create mode 100644 plugins/enhancer_calc/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/enhancer_calc/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/enhancer_calc/plugin.py create mode 100644 plugins/event_bus_example/__init__.py create mode 100644 plugins/event_bus_example/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/event_bus_example/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/event_bus_example/plugin.py create mode 100644 plugins/game_reader/__init__.py create mode 100644 plugins/game_reader/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/game_reader/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/game_reader/plugin.py create mode 100644 plugins/game_reader_test/__init__.py create mode 100644 plugins/game_reader_test/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/game_reader_test/plugin.py create mode 100644 plugins/global_tracker/__init__.py create mode 100644 plugins/global_tracker/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/global_tracker/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/global_tracker/plugin.py create mode 100644 plugins/import_export/__init__.py create mode 100644 plugins/import_export/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/import_export/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/import_export/plugin.py create mode 100644 plugins/inventory_manager/__init__.py create mode 100644 plugins/inventory_manager/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/inventory_manager/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/inventory_manager/plugin.py create mode 100644 plugins/log_parser_test/__init__.py create mode 100644 plugins/log_parser_test/plugin.py create mode 100644 plugins/loot_tracker/__init__.py create mode 100644 plugins/loot_tracker/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/loot_tracker/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/loot_tracker/plugin.py create mode 100644 plugins/mining_helper/__init__.py create mode 100644 plugins/mining_helper/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/mining_helper/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/mining_helper/plugin.py create mode 100644 plugins/mission_tracker/__init__.py create mode 100644 plugins/mission_tracker/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/mission_tracker/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/mission_tracker/plugin.py create mode 100644 plugins/nexus_search/__init__.py create mode 100644 plugins/nexus_search/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/nexus_search/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/nexus_search/plugin.py create mode 100644 plugins/plugin_store_ui/__init__.py create mode 100644 plugins/plugin_store_ui/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/plugin_store_ui/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/plugin_store_ui/plugin.py create mode 100644 plugins/price_alerts/__init__.py create mode 100644 plugins/price_alerts/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/price_alerts/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/price_alerts/plugin.py create mode 100644 plugins/profession_scanner/__init__.py create mode 100644 plugins/profession_scanner/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/profession_scanner/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/profession_scanner/plugin.py create mode 100644 plugins/session_exporter/__init__.py create mode 100644 plugins/session_exporter/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/session_exporter/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/session_exporter/plugin.py create mode 100644 plugins/settings/__init__.py create mode 100644 plugins/settings/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/settings/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/settings/plugin.py create mode 100644 plugins/skill_scanner/__init__.py create mode 100644 plugins/skill_scanner/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/skill_scanner/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/skill_scanner/plugin.py create mode 100644 plugins/spotify_controller/__init__.py create mode 100644 plugins/spotify_controller/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/spotify_controller/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/spotify_controller/plugin.py create mode 100644 plugins/tp_runner/__init__.py create mode 100644 plugins/tp_runner/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/tp_runner/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/tp_runner/plugin.py create mode 100644 plugins/universal_search/__init__.py create mode 100644 plugins/universal_search/__pycache__/__init__.cpython-312.pyc create mode 100644 plugins/universal_search/__pycache__/plugin.cpython-312.pyc create mode 100644 plugins/universal_search/plugin.py 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 0000000000000000000000000000000000000000..8c67380f6988183d5fcf6bce9619123a3de7a4e5 GIT binary patch literal 118 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5af77a9a997d01f06497b33405834df64578d13 GIT binary patch literal 203 zcmX@j%ge<81aF`8XF3Dv#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O z8G(|TjJNn5^Ad9^OEQy-19D2!GxLg=f#QCe%(vJI5d2&0@$rc{Iq~r;89svy|0N5N zEY?qi>DG^r&&& KBYP1$P!Ir^?lR~A literal 0 HcmV?d00001 diff --git a/plugins/analytics/__pycache__/plugin.cpython-312.pyc b/plugins/analytics/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..222bbb883884285f5a5632b92ed8351cd64cdb74 GIT binary patch literal 26052 zcmeHwX>c4@dRWihGt)SUV{kVP0vH?vfB+AH-~j@>mLzzuR?tebL-YU~XfT8A8HmIL z&}v;T>r+S#n+xXPK<3^Q;qQ%l=S*-Tt1iIi(M zO3Kdny*_8qL&;lmk{@Xnzj^(xe%E)u_wc6$1uhDnzkc|7=>8Fk`YU`<9-AC^^4k!& zPI1&Y#hEyB*fe7vH_uqcEi=|}>x^yOHe(;R&p5^%GxRt;;~aO+xW-*G?s4}FGtQW> zj3rz!Q#f8YQ#4*QQ#@Ww;;iA4nbPr661Ii6%xoFoGE+8QM&9k=^6_#gXAf75SCHSz z@k;XR8TXi}!xZQE4#m;I(zh*A8^^2W?Otc%7p{ZnyU)i%;ZS^@9gYOT^YPF`j6FLa ziw9?1u2X#I<-o*z_hfWpE*9k2fSTh{D1MQR&c?e#5jM^TCSD3frUqTz?4e*FKF0^y zxmaK-$j0Jb>0hk z5AuAJXZhf4l#fFuh&d7rgyEAD7lRWo#ayo8aG2!+@crou^0egOE#PPM_kVQ+zNMI}qUE?OfnOI4IXztbaQSLnpd0Mox8}KFmkwW}y|Y23n@$E}=Y+{RhQ?VN4g!P&=YTmj=wjt1!BoB-V% z%elW}8fV~_f!_kI0AdTdLV!hF5x`=u7+?uk0#du$rp|Si{u-tmSF})^T+J>$!S>4O|02cHZS}6pPe#Cv`sYu8W07jfGXm z=@$$ah+Vu84e%Tb%Zb(2ua6WX;2f^V*dPh#uE}<3)q3SbX{ocb+TxTGq$L=gn3&`F zAgtM#94{|KT-tL|oaj{Bny~fxdVRg3>o~0E$XM|5TrAOXp5TIpJJ=uH_jG+rI4=bF z?um;5K0aa6&9xQ6h`3MQfY|HQ0yV8ql`{gmo8Oif#RBD5bL7cw;jFsc%3{h_w=Jt~ znTSyNa@#|cu5N5SdGfTxF{RI~5Yc>f(^e0&u&hOb6Y13+|$pQ+xfDYp<+?*2+_r zJD04lF&m9r->kn=pX?pEdoI=Zl_hJMuHUF>xV`tr-qr5)@>I<}jPgKHi1gj?tyZoz zrK-0>(V`8u<;}u7g{zC}7gFpnW~3fbj{8?;g|pv4vuqoy1Q4VX89-}2V z?NCy+Us#mtGS*_>mcM91)G?~vt>slhoGGi6iVjLTMJcU^rEd0G60K)(Wgz_uMY+&K z9zx~|_0Cb$nAgrDGKwYo^@uBu7ajP6OxQ6IjYNVIadC^E4^BjRPQU6z8(F1%8`L5# zH{J_Dz7;^CJ9j-jb=8hw6=MKir5-l6{m>&VBAUt2^#Wa=p<4yIHADLZ+P50d^c)s? z4yWlO8(MmcK)0-%Pt%>SDx3uwniXjF9^J%uLX}zT07(*A2d}^!T&JA6m0&b0oar62 zG6#z$ZPgg91!XQ4&ANOl354&;kDAZ2V3~f(oK6#2Rw*U@a+ZXfq9nLzjTb1XPa~_- zB}y*sS82n7X~8^Qs>Lgz1#4U-tkY#mtoEyxr=}~kSS6HzmUGrywnZDPb&nFO{Z1oI z%y|}U$~c>-c%71HTwck^*f*| z#>82PCF#-D_Xz0*5Oq2fi3j|(#ln|Ee0(kt_Di1?9UzE`?+-iItnU}SS0ps;`^0UpC|o-9aWPd;l3^MJrZK~`3rzcJMVje_`ii$)9ms5H z7q+ygioC0%9~Jd(w0C9N_XzEK)`RPl$@YU+t?A;e518WD$F7Vm?^$ihbnFv4_T6Ll z|LS3p=P_l19s--i)MVZ6_2KpYWX-+|vtMBL|Id#tnEF`^G5t42OGnG8pO!mEhb%v> zFB{!y`RPs@gvTb3>M9ScV2KK^!vGSISxM3T#!U031yfut_cWSo#D`THWxi~RnN3uP z;>@=!ix$9K1O7Y>bT#)`uqc!UxV8z(sgy*^`Lc;GLFb*Uo#-l6*Dz$AQItSFt84kqaexz0WX-MGv z5u8C#i}`3GP7_MP4`b{B1X~e|B48050^qe0iXgg3ll-6YUWE8Ekw7X{gh<#V_FH~fOTYhz&h-BF?A=+fxx zC$F4b{))h~J)oJ_i>?$cy9By<<#3AbS~CfBAN=0$-~Im3dqcmsH`9Mi=s%X~Ke1%h zavl@tUJ#L9FT7HC_0o#<&7wO+_vl_pBvO$II)XYKzCW2I$fyY~3#$0}B`ic03FE5B zV}yn((o#m^4-qU(x2yz-4VLG$!BW;Eaa4blN^h?biNZ7?lA+F$c1M{h(|xz%8@eqd?zwUdZLhzOTg%HC|a({PXO z+pHU4=LjPFi0B3yA}EUzGyoNGG~`$`gAA=2MxYM)(Q9vURZj#{4WJo5u!s%ie@@SJ z%%8`XG#x-~rFFq-hzQivGy%=v1iUU^d4ol+e3i3l%Q|m)Ri}_f0HIpm{sqccYUPxW zTHf&m$|Kv$dDQZ>uDtQ6Ta}pu4Mo*pIHwW|+Kz1+xm3M-JP>#$I61jnq))yKdPpdENp$Np1<^(fsiOTL zp%WqvmI!nX#1hPz;3OF4FS4Ux7xR|zh(SD}n&<*M*4f#>1Z3lpl!^ry5uXnS&t43I zJHQ#>ILS)SBNYXefaAb$FP8ZyNT>Z#xh4KM#8xV?AYmx343bcyBuF+m%v-8NNmPww zQ)`7rkd|XCk9Z4KkZ@??B@ju_5+*WW3L!o6gGU35D5!ae_Dhlr!ycBr7q)mP9u9)Q z6Sx4XkW%16JQBm6qU0fK9HJdOAO5+JZjxjJoD_c*vd26?GQcuY;`x5nlKp;3?ebSs zB`r(#4Z1KxR|$00auZn2fDV>aCM$bV#l1_m4H~QWPp9cKB&SE9JhCQqSwZvwa_YX~zB-yI>lVtoQ)NAC)oFStE1_2?>rItyTRV}a4`d~D2xT3qvaZ#s zG`%A$p;IX9OqF%7UQW}yvJ&*)cz~@ZZ7KtcA+)KoX~mbS=v#7c&|9R&XXs{>c}6oW z+l3a8m3CQU+EkT`99+3)T4oimlD9Ln*gt)6K0qGK+TIc+i?1 z3})28)}_>?xB9@?sbZh5E@xI<=p4v-^3kll)s zvoPT%USwcGKA$hqtYe9NJB(Rk!U2x(LAJX);W|M)<5KuMv8$1A=~>BT!=6=r?4Ok! zB<>s8q=u%n1T8U32$c>`#6vF!S=n2Im3T`vR>>nG$vpBR@wNNG@&_(XehL#V0uY^O z1Pl`|IU}3iCGpC_^6!O~EXXU*rb-%@?7&|xXrRjdCtD0SLrm8x?Ays(4 zwrOQ3RojboRAs$9(8{8lD^2%g)z&AleQA1|QJlsbYtwWq@{((S);1-#?)d%K`aov)n6P^+wew_h$EjrV=~V3*wJ~3NXJW18{hs%FQoaMp?vZ5k zXsY(0lv>xEY~7Wr8zh~7K$ponRXo$sD>U?`>AsE6i|Z2_um@Ue30GF663I!2?wC4( zt^>K_K~d?FYcs!A^{$rxi~L$gTUD+Bv{mIAx^z8{MOlM9d37F@%PKju%J;}J7pgBE6oUV@GVmazCpG(0yG86hU*DtJEv+t(7H%Lc;0mI2@HW58n+7oU;R9O zGzp{n>*!!{^p8$Jv# ziN<_5s@6io5{kVBdBJ>RAd=R$-`qxNQQx?W6wjH2u6T z(ksy36y2GjhXs20E-dK78rqd*s=Pv#7iuB)7fG~B)9mLh`M>%9OCGk@=oas8YA%rPzs&tIge5kpp0>=^zEjM1e_eCvn9%9q$7GCY|@@0*v2oz zmtp;*zecnXB~^5cNRFp-iK3BkFaUddQB`D*N2k1nn^2BaC$ZQ+LAUDz1M8A=gD#N_x*57rpc_|OGED9h&MXaYfQ71G2E0&>VM-f-g{rEE z76&;ER0$mh8Q1jX;imzEHg3JvCe4)x&NP{7K<`zC4do4thIxAbbmN_IO<%nCpJ(JV z?!7_lm41TqsrFM-38D9@u^4*KYV)gv^7J09SvimPr93r#@!q%OR1L4X+KpXzhOPg+ zQbtB=txUX4X@{n`E~4|dU5VFz^Kh9CEr$})>d)hZR5=>Vnbv}AH>irSO0BTTNi|sm zt+4$g+SsMlp@crSjVk^SUWsnIoF|79x(ytuiXV`$#JM-1f{$){W=6{1@PtgbqhnjeHCNOa^6bbsEw01%Yc5SlpC=0tff;?2j#=yTCtA+-ObK|!4}Ng zgKQu1?OuSLoI$>u4{)Km*dRLq#_e;7;)CSygLa@N;UVX93|)9NNJ;Dkq#AMlWv%Alo`BBckD0|Wj2J9c|L zI>`cc8Dj1h-7quHLT8@k1GAD%0Bvkyk+jv!Pfnp-fJ0&EUnaJF@Z6#$2)4CHr>0;R zt0Zug`teK!={}SRM5cnASU~IwN)6-y2iUqHpByjcDwM=~H5)nNQG(f&1d>maaKL8_ zGYDfL?O`pFMIG=TlL%sNkUhyd>BccDo$!NAY*Jy6bHoBkG1cdB6sm77{36x9lJt*qQw-(}unh)@H%xD<~Xd4}#1=&sdsneI`c8)`kM)e7oN zw@}rcrhQO%eOt0^I8}E5>((3Xw+i&u6)w{{B(x5t={@ADk~tsu+GXetf$mrx$aL-( zI`^mP;jG#_g{sap-37Hbv?tpSrRoo3?KPwhX}=yGL0E}r+I9+UJHf53OFPpkysEWa;p_+HABFSz8V@#SJcnI=e&; zOP+43K_=UX0nnv*SiQ=WZG1;l%RElA1^a&T9mxjjQG!!and4yNhdT7x#3 zM2yYyNW+N)`8xprsg6o4z;W7_U=hM5ezTEzgtL-V8)sXzaP~!Oqz+0*arsI(qRN8!VRSR@>SvXcwy-2@8&%;q({M8*RsY#TbC(c<`@r@D* z=C7sd9_-@>AoOS(f>QwIaLoSl*7xN@-17-X^T0%4a(nNiaW(a~R@i3)d<29ENS&N~ zW>;_T9QvC6^44{Bgom@0aJU;&2-{9{egPl4vaReh({AD~7-T=Q`1&S4GpT-qK{iwj zsPkwDzLTI8=X%0?krhod{AG+SJQoEEzqD%>B$|Zhq`V=Fm23yX|2Ov6tE1m=4OY)^BF#ew*Xhwk3BU<3>4Znb|iwF)P*oxp41UTRPVFX72c%9OQ zFbnuH_@fXZ+9g*#?r?{lbfR5y$BVX$AP~l2dv7Q*7Z1kx6OcFN1b|iy@<=CAz70_c zO;S?a-#PyG0CMwp0pM|zM(6H_rJhV_i%{CKI*~5jwltD`8U+{;oCrDd-OI~|Zjap< zTRrnpZO=wk>s9-Pr}3I?qonLA|AuFI@^<7#WR?G@zE4YgP}N3qUA4hkkqlEWF!dRx zRbX0IUQRPUau8FB>k^o*WY2i=`EMl01IfUJWcNgx;p77*8Ky~KnpV!Q_OHfPy=#5R z=Iv=_2Ps`AFm)MbtH5ksrPnInuYa%pt|i$!dUrV4dMM2t1_oVGef_y>&wc;-m4P?+ z-PtFUZ(BNazpQfEzS44|FjdyJbnt#f)$)#&(HnbK&j{5$sfu1WaD$D_s=9p5vBs~r ztykS`Oty}unS(GUcy>n1lx%wyx>xp~Jm~w1pH{_O1&1_!_tes8PJddsFV6xr}J>B zYkQuJnDN`t1bp)CTvpPJw(e)2Tm&+Q^dsjn z^gmdEE!u!B+P?r>RChvY6>|K|8bDj{srh?yht%z2=$VqIMhu7tEnnp<^O{I6MaX;#f!zQmf# zvT80@YF0*CYwv6S#!Ah*fu*-99VaZ^2{Z3iVzghbV!^qIrMnlMuQ=!3Agsc#j-^*7 zDrJ_gbN))E`rm^p2}kF5KnOOoY3_X+2q?!~hE5bQ_rT?9Wy z@I3@02+(Ur&Vus)0zyRVKNGz~Jbr`)i_TzV4o<7XHZ+muVKW;K4nV@q@sbxFJD_9B zF3FqsZ!s3xGXE7)sl2rS_Z#3gZ9G9M>FA~qe;jkr(qUORN<$blh`*G?oe@eL8cjn6 z?s}O+>t+nq?thMqIwLdcO6LL7(>eA1idyZ|Xmw+zx?8C3PF440s{4iN{xwdh9$Y^s zR1Ys5L)LoY$_eT8=gP4)%i755{JJUGG?-?P31ygCfvL?fEdtZB^2*xe`;qq|ch4rb zAN{Z-x%F6@IiADTlI^?JYaqj}qaSVomX>D5fJ4?aWU70BsY316B;4U}47nSeQ_C=I za8T_X%j&UI`>r(e%)`3YA6;H^r`m>6b$cFDwt@-MHRn~+)g2o(jVql(jc=7-Yf9E^ zze;Zumt8NqR+K4j6N<}i^*Ys(*KR*zCg-%AzeK2`MFcRz`f!NpSZE@zxuS0Dz3s) zum`kLutV#o*GG~y`!me2zzh?9en2?|d)Rfjfcl`od3eC`K}Ff&9?J(kHV9`ibX=f> zp|=7`~j%NlJUep@b)TQ4w~-fwF0)pbrr$1DI13W&+?H!Z8@yHqhmts8 z@5*r?w5apd$nakR!pN6Hh+HgSWKrj6I1Ypa5*}i2kb+q-Py1b3`xjITyVlOv{s5SZ z9X2+X$c%tk8YLD$C~`l1L^?8i212533<=`cBQzd}rgNW}SpEYDdKvz=Fyn^^h#tdY z=uZHEo&p9+prE!8kk1(Zr4;EklOl8 znjU=E)cMY#wSo8dy|*vZe?;g%lIs6Ts_R&)>G)&H;XGmjrDSRN%D1jMmdkPp-)Y!c z(Ybs5aHyx0 zKC0{6sNK3UvBEEpWoowxwcFPE*Da~qp{q{gir0^(IuG4r4u4XFt=zm37Hvv|BK%hv zi2qk80^VFzy$OQ=_wOjUj|U(nJVU$z0W%*J!2abtKn3jD?NFNCOX;i+FY$0#&Fp*vijHoW#jz-qI%AWYX{!#?7`L97pgcE`MZH&_Jy9r+rzxn@wARDXs z48+Mt<#|MA{sjd85kSs{3Ycu84HYs5Ya1$l7g1P*%%6wCp2i=wuMWJs@2!37{p$nC z&i$#T;gsjVW6J6bn64aI8eW>)sH|OnUa0i0Hm#mXR`xC($%9o#Qk@6W%%MD~2k&}? zq2nK({qRC^=ya;{Oqw}soD(244!xM_^xtE? z@re@~w;6A7QV8CD2LkzSfK#|AS$PIrJu4mQXU>zA?#M}mS1o zg9{j}{I?+^kMb&yB$>xq-E@7^EJSWT%&kB4h_`mcwm~*|lI89F#3dqctt4T*y9xH3 zkudI7xkoojgNFmAJXQ#h!NWBRg9rC)_?0v?!Ws+}2ls*~C65OA z>xRn5U_&udD*h0HY6Pf4ijIqcSRf9X2WEm>-Qv;Nx6G1t(LsEVK`w?PspuiA5H{HK zw8_i)6P&*DkQt`0vK}_zGB88rbo~5E8|)bT3wP<&c&4)ZqsnfADe}!~e_rCb_3X;I zWar*=-9D0lMm$OKzsGpt;7o}hVw(o$9UPd=dvA07?e>n<=>+auZ_oDU{Xk znlri_PCg>G~+)dzPLUV>|(o&){ZM7`D^U&`iARLI*_ zvN!eMRV;349|qi`7KoGkh{P>A3gC~yU2L#phupuCXnv)~7o6+Hi_+lwX6^b-c_WB# zIu?zb^TNGu!OL(s#2bMkbisj| zQs}MjIwTff4_wFV({LjjBg;6h_-6s}zp zt=K$J=;rWtxd`lZnw4yU;8|78k{nz~Wo5{9|ApvYj&3n+d({j7#2Do)P;7*ug*OK+q zVyd$0`qZ_lOhvm;fyXve6(`}GB<;eR4Sw^$^~2W=Uq5;6=7Cx^namgb~ z7zRqP{1XIi0Ae`AvZ?lWA(&`Tx1A}@RBcZcnlMoNj!i*M;HcHL4!iF7U&X7+aK|cB3i~6J&SJP%Tq%NDULMyIQWHCTU zdSEfA3qy&+#gH?JPgNlZBQBOEh$wHk_uofm1MX>)T-szP@4y!}jp7PKChGExqNEG{ z3dgV+@@ZqJ-oS(dLIxpAC%{=!9z+9zHZ2hNe1(cAJQcDEed!`B6ZApEE=Dg!V4;|J zvL14lg6IHo0kb43b2}m@{5^I7St@h^rwpg>9$m9}wot<$tsR6CdZ7Wd2~|%46Qc3G zzfYYfWAT9x9TpP`FvJyYTNQYe5lSeC;8N(`y=ooBL_M9~rveFS`72KR7oR#xUdee-mzN zl~WvkKNp>VXe;6U4)menNZJE)aX2a~y18I%f)7d8ty)8{^_cI#A!|j@ivZO~{zU{Y zBbY|;5`r*-83Yjo-$w8T0?qJ1&=Qqa9yJ~Q9Rw&f@JOZk6$JkS!QTUTiy~c<%*kl6 z=MmJy|Jc6*@Ez(CGi{?kE~A*rS4TGr%U?abQBd{jp^ehLuO8cQSG+p<*jj4qd))4@ zH9sz`wGEq|3~jOXJ?SX2ZF}6*Vyk$3+|+5?@_5|T4Dc(aVt_4W0J?erT!7f!5L;zC zV0yg6Z98ImTus{orpIMAThrr0+Sc*7i~x(R=dr8UcG~o~g+W+rg>=Sjt9?SdZADTK zu-)Ej9wblLvV3a(bbN>JbX>aTDyG}+jAVh__#y2grtuZ|J30R>+2s+3$;}`lqtr73 ze{6%?>dm7x&m#tdj4E9L@4Enh!on}T@OWfKk{gMS_Y-{mZwSaXDS{Vdl=1wcHu9g* zL()px%fp{S0kIi|AK3)l0BbURLc2`XUwW)2^Dij{d_qDl%G4`RJ^z{N_yyJebISd5 Z%K3Au00N)dqYhKi)#<;Z@Rxk&e*?G=$O8ZX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e30b211343e248ed5eaaeb699d7d3adb58fd0837 GIT binary patch literal 260 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce23582c1b27e92b195509d6f48782550dad2016 GIT binary patch literal 360 zcmX@j%ge<81dHzWXI=!-k3k$5V1hC}O92_v8B!Rc7*ZHhm~t3%nWC5&8B&mG6kJ1fLrXGqGD|ACz{+wHOEQxa zb8;#bk`oI`N{dnzisAZ-74q{G+;j4i5_5DEJpA2s6o3Zgmt>?CaTRB#=Vhh=b>@{Q zq$lR4Dx{XB=9LudalK>&I!}}FmI%lV2q!>XP{a(B^wVUy#U3A@lAjzOe~YaE>@=_h zNEGOj`1q9!pFy7er3{fQ)=vbQ6_0S2etdjpUS>&ryk0@&FAkgB{FKt1RJ$Tj*nw;> j76%d^m>C%vKQgg0vV7%ZVAOoTt$2Y;sgb>i11JXoM38E= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c9619c022515fcd8d5ee22caa5188d743c7d4b18 GIT binary patch literal 40279 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 rEJG!rouP6q17270^^?L3L;cnLpD^HkVh>vl 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 0000000000000000000000000000000000000000..96d5bc942befd4741bae5d8c3d64217d3b341f23 GIT binary patch literal 208 zcmX@j%ge<81c6WcGu?soV-N=hn4pZ$VnD`ph7^Vr#vF!R#wbQch7_iB#weyrW=)ot zj6g|E##@4pr6u{H1u2OosYL-frRkY@Ma)1MKTYOaYy}AZE%x~M#GIV?_>~NwLB{`5 zfJheWCj#}ymqHBCkB`sH%PfhH*DI*}#bJ}1pHiBWYFESw)Bv)sSO7?TU}j`wyvv~a QfLrbYmwY385j#*209`mVX8-^I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8aeada485ad6ad11ba58d78143a2c247e435349a GIT binary patch literal 25950 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 z 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%0D0u)6)yGox_xg!+}X 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 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 0000000000000000000000000000000000000000..73b0248d7fa7d4773e61369a0ae58b3e4c1f9147 GIT binary patch literal 261 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 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 0000000000000000000000000000000000000000..2aef34b309b4bb9b42e762edb11e0ee758461c69 GIT binary patch literal 248 zcmX@j%ge<81hb9%GZTUIV-N=hn4pZ$Qb5LZh7^V#wg}W z7ERVF5iaM9#1aLc{PgtHB87mQ()7$cu9u8JZJLa?1VC~SDTquFGf=`$ljRnBe0)lN za(w(PwgRvgumngnF()TJekH?akOO|nLnMp!lY!>M=YZ|dkB`sH%PfhH*DI*}#bJ}1 upHiBWYF7mE3&_@DaUk)5nURt4BNG!N%U2EtM%4%0vKP4I8rh3DfN}tIcR#HF literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d2a53a62bee49df8090982bebca308873da7840e GIT binary patch literal 12670 zcmb_CZEzdcad*H0H~wh0zX6%GzpQEL`oE`S`;Nq)`!JNiW9gh0^*J&D10z? zM^Qw;soFXX*ikAmPC{zzjMz+OO4V+|G@Xh2r=w)1zdF+)1UNvhPOE9vY4wk$oKfs{ zCf&Uc03=Vgo5@jn*!Qvf-rL=`Z{NQ6PY#ETfbU!XdOQ4IZ3OWjn9)9y3OxNA0G0`s z@DnV_8X}~hlz$C=1N<5z#;DP6j8cAz#Cj@Xikkgq88$^MQQA+-usLFl+WfYt-EWUF zekSVhJECR&vS_)#JX+zeh&uhwsLSt)R{AUD_LfLhwAx=Cb^G0No{rQ+YyGt{Y>m`K z>;3g4VIW>6Slb%}Yv*d;F)HKrH_RKnOya!l03Z95Z$K!wtr6;Di&@}|jW2g9Fl!H+j z#ze?nh5_*~2-`?d;FL*1t^!682#4aaKr|SGsqrC&PfALwnWxtQSSC0EgiD}c{YKW{ zr&y!k#8Q4UYw}xIv!7-yEX`V38*677*1?v&ZSh;#@;3;-jje#x&N?AwSQn%Ywi41Z zwhGd6ww|qigY;LhZpb;=8c1DiEu@w6b>0T40>z_9M;Ujb>C=Sc`@~)%&xEPZ*X!$* zm^0Ct*@zIn&Yik=DX|v_bgQE6mc^_HbGw)mOi1J-F9=iN3GoF{qRt1yG0Ac%9tv~f zyhL5*f>FtIE*=U-B=gIW_&EHUF7rW_lPnkc@O99I`xc3c%2%kDDPz#85)zN1ng3DK&x zVX1dbCH-m3pFoU~KQrh6Xu;by7?WAUg)x+MOYEvWzCLCk~JQsqA!o(Ysj&4iSZEK_N9o(SXvh+(InTfjQ)hpTB7oe%*QC(BM(UXU? zzB%8p2UEy4H04`5@^#H0R@-ZB(2UuvC#;SfU6-co-lrR$LYKrg6-PysRlEZxxJ-b$ zV3Qr+v}(aii!~MESt}tHR8k)wmW!&^hlmAIEGYl9X5>l;Cksn!!2s0cU9hAYz-hF-Num z{N+NiMvkde&tLJ=e?J_D!!ivsQ#e3Zl(R6@2+pHEmQ5=Bn-~8y+|{4>HdVzvCtWJK61r;K5w!njQ>ac#A1NjbSobqMWQ5 z|4NkA90DZ`85Rt4#4e>ZD@yCNB;1#R*SUgzdc-*TO`PvZE%XG2%`_FCU9lFebitCe=yI6WT0rmm zlnXTE7P$C#d>S8QUN`KhgeR^M;Nouyby`<)y7MbyiMW5rA8Xf27pzIEuBI?7 zQ=%zWU5Pt1Dj+ZY5tZ`MFs&!i60H$m(8i}pZ^4$d>GoV!?7651$pZfu>`A-sS~alc z;Nh1S*J>DCw18Xd7x?be;NtIeUlGUP>7xl69IO{6mtU-6iy0nT|C6=*LD}@>C(S`>z zKqqs@rz_1}qxQ~Ejt4vY4;||E6qCL~-lMxvw~cmJ?RcDLIhah}%ngqakA&e^ zqC@yVuMS}awFlxK;aOqGgLB_SekRDW;n?Jm=kU(jg+kj|^dvU#efyhFJOBg_@8U}_ zjP|?bj5haJJjU%F`sbNmsq7Cve}*W*l3?J_RQx)GD}P%l?-E9lm5B!huF8GNam3Hx zQE#_L`FH4Z$a|MWu}6)ipRP{*JINPk@C1);mkVo5PZ|3T1NydQ*9)uYT7mZBIgRLNte5Y)zpVG-X>}!A#3AAFdB}`4|z_+W3Zls zLboRxkHx{zg>-bhRDVHa+esFMlCYmw!{^Dgk`2pXbYLYU7r-!X2xy4H`5+I$e1S*%%i}qOM;k5KFzP)Mz7Y-1K)8+*FDf7d z;HmHpj=cniSh9lEQN#2$056F=Cx)ivVkKbDqcaVmMcFC`!m$~M;bZ3)BfzF9FxecB z$|Kn^QV563*d)hF7KpXSxR40P&)FHgF2JxLTqE#y9339SC5KWH2w^B6f}AH#AHQ(n z#b#V>F{_En5e+Of3| zh{)Fq6%b;a;5lJRYwImn57BeV4TTx-@p6JzE(s=ii4ntMgadrJ+@|EPRKwl^12%;V~YH@}p!<=xFWw>Rzf zZgdSjaBaA~8TV+)o~K<(S1Ye)X|J5ylcx8q8dgWL^u9b@m7|-}bn{9gOLsl4aNk+T zRP0Te@-#$G8`5;cM&qkl`eL!!Y?j_D=a5L2Zh3?=sZG7=km{^A??QB=+pIi?*buYTcurgPH)-E3A6)|woRYLlhgWEGO-qt33WW4BND! z<*J~oa&=wlx-MuA%xY|1?a4Is;;d_BZ0svXcckf#jeY(s{d#c#faguuc|p&BQ5c=a zXn&U8^QgQ!<=|g}MY1i1gwY>76Fhan2Kn2B=%)+uBcP&wnzs#pKW^ANK9-cvR=RP~ zX|$F*K8+fjE+IM~pDsy^nCgYljRT4CXq9(;dT^j$^rw(R9aXrsGtKy3H&y zcdo)Rd(_;TYwk@q_vV@h(#->z=EGYMqkM^6ya0>FkncP2lg1x4{Chx0<&P$m#!|JF=<4J7bD~lTR#N4qocJD4=hF%Dg8cJ3m)}(&!^QwyiX@G zs%hqRk|%Fnhj>3NQ=&Bp@7L>z7|GffOwSn~+`SigU}no8dQ-tVSr=Q$Rj;F)2 zw^#u|0} zfA@T*vTKWIu{u*H9=Yq5CzmEy!fWn~yEpZ6zOrU{cxgCS*_N(sTjkbXUOTeUerR3H zRu1PYY9Cd)mj{;yR|Z!5GnE~w(R_L3?c`!|<^03)E){dtk*@Tuvp<>s(e#fa>B^CO zRU?>nU*Ed>uU@|+tPCuDZMA=OJYC~`Z!&c{UtPQW+R|$)Us|2dRQIJ$ZPG+tYrdg9 zU)z!gpuP>$hV~E3X?Gdax`^tAa}&}zW3X*JhX{E9F;oSTEdfX!26iLWYq>8*dc z?=SYPwXccm#!BY*FH3s z@^%IHRNF4lbWMtirMGox#yx3B8o|JkZ&M4;*+BHRhSG~1FlT6Cc6=KB(u*GbEl4wV ztRVre7i+xM2|DbAwuOtIBIzPke4hX>4;n58VKvFmrr=qQVj$I^q|Zyq_D@+9xC#{O z_~!_r0Y(=HdC6bIWDFCGTJv`>!R5ez8Xz$E36**>4n%VO{ z)3RwK7_XvfrP~s^!Lp|P9RRXp1g_|`>iR%Y;nQRyJ2|Bk)E3|-!PN(7ml50yoz1?~ zQTjCc*YS}-2RIr!j~7by@z!D3@(mC&HzumoFg1$7GX?Ln;{3d?xJQD5pxITVE}LQ$ zy(ViDJdot)B@2WkW+U*z%P!~Pc?mko--U)dWo}ji3OdJqr2^iU62fIb1k>rNN?G#m zhUIHZ*WQe#?2o`izPJR(L4&nDbs}G0zY@APd3SPkVr@Lr+?Q(}Og9f^nvXmzKa#Jg zSuR^DTM^!`XxlVFbFgm}HK{U1fTiLo2oU}9=be+_RR^iQ>Hy~s-hO<>?K^A0YPgyd1k->%{#{tGYj7PQzs65(}2s?&I z6dFRJao5TzsfA~_gwO@3z*cy<^WxnXe_np(PH^q`4^Q4dx$ez$4?mzY-KW1rJ}f`8 zL7({;egqnt->&PW?o++gw!I>lo&FW)z3RKw zE7w=~OkG#5t~XuRo2l#1R`#b|XFnEjJN=XH{iLdfk}(*Y*It@AGB$ST$ zD@sVuiHf&vREgHl9(fH86ZaMva8k^Y5$fh zMw;APUol!to-J3WX_S0=nJhE4Zh5Sxnk~1%^a}ZuHk)k9cyJWnMjm%E$-r~+LoH?N zM`gfAyy8?OJg&TuwS$Wsfv`BRLr@;YmS3D769;@_q7s7_ 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 0000000000000000000000000000000000000000..95b6e6f1d45e812b1fb57955f7ee3000c3d86279 GIT binary patch literal 254 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 b`-6j`YyOMc`GxJcncj7y>JJn?3GV*`EIbqY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb61ff8a10c00bc8c75c803e8e5d1737cf158244 GIT binary patch literal 266 zcmX@j%ge<81pBP}Gb@4gV-N=hn4pZ$Qb5LZh7^V#wg}W z7ERVF87}9d#I%ykymSTU#GK^PoWzp+B87mQ()7$cu9u8J?V60YBw%U~N+2qVn1KrX zG+A!3$H%ASC&$O%Vk-b^1xtWL6LWIn<5x0#207@LB1E!SKN)Igd@|5Z{rLFIyv&mL zc)fzkUmP~M`6;D2sdhym-+}Be76%d^m>C%vKQb{fvV7%WVAOrUt$Kk=t&zQm11JXo Dy^TkU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac2302fe19c755d050d62ab5f265b797554fe889 GIT binary patch literal 9809 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 U)Uj8#Yi4?QE%hyhOJ$h<2YY%t9smFU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb94fc5ee4e8ee6b23c1fae3c6b8add5f00d1c8c GIT binary patch literal 243 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 pA=;kl^cw9mB0qra=D#6Y{s+= 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 0000000000000000000000000000000000000000..413c945a101fef65eb31a0f0050e6d3d01ca9d2f GIT binary patch literal 216 zcmX@j%ge<81n(dAXZi!_#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O z8G(|TjJHHxGK-V*i&6rLQj1gbl2Ze6O4Bp*ikN}2ewxg;*a{H*TkP@ii8(p(@hcfV zgRJbUjQ{kKR!M)FS8^*Uaz3?7l%!5eoARhs$CH$P!q`7VgVrWftit! U@h*er18$`YT*{5?MeIO90O4ggI{*Lx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5a86051fe97ea68b715e94ff93895b872241c5c6 GIT binary patch literal 10267 zcmbU{Yit`wddv5aT#|ZF?}t|pKWIy|C0n*4``oi6J9f{~`61`dyreveyOyZ%)!CJ8 zvXbEg0#puib&dS6)7&At{5U)C0rdfG5%f=+G(dm!4|$?Ndd~->X#U)6DnvVV=8?N-lEeupkQAq`>9V%L}P2 zcO{3plb41sNvU*7Dnxx37jxG{F1d`ckd?TTSqbOzsRVZ^o4P7sQQ#6uDRmVfIT))U zj{1H}W{e=&bN+iNenF6A|M}M@bJx!%3b|$SaoYHBpFf+J7t+vpZdqKMT$ZF{ zhWH?q4SWd7#zI4+QEt&zPB&AfrZ{4~)2XZ^EZ~Hc%DuGR#RfWrqAI#Zq6eY}WZzjK zlgrKu*O$d&KRFaQC9BQnL@6NwOfVyeVq!rU7XNxVfrTX946-Lq_$N&9{3^8GWmcFa zb1#8_hp^;ByINmi;yOefXb0~!L2KifdbnM@+XSsKD9q`BnFw{t!`tDD3Jc?9c*J|t zAP}oI8?$1AK93IRhA%|FfI@wK+Qu-r7ut2)ruIf?Z!^AW?V(n&F$E9#8(^d++BeAH zE}(%~YN0L0oA*7`_6o$3tU#yhGGMyKTz6b%uGu2~V%wQ)N=hZtsiHt7hr4iYDk^(~ zY+^nw@TX*NGMxgki>G**ky#N=J8#4bBUA$>&|E0P6p+*ZG;dBpv^ zs+Zz)5&PASXucr(Qu1NS&tHKsFAVA+T;?JSD$$Z|{b@#@d8|$e@EgNk|)|~IK zwT_+-df)4P;D2;sqhlOeg0*h$gW30H9~}Sq&_?&S*PIp9QEP649#re-B>`C?$KTy_Fg^R$j(z=QfNAJ@ z#yGsq&{bu-%WQY4XRg9ts`d1f@lV*MO%L>LiDbJ!X$en;nNP!gljDw0kJzBxCb2{c z6p59A=kGF-E{&8+S8b9mMJqPSfvffv`;sm}fJ1wD9m2*)h7{TLagG(ol9xgoZ{9&6 zfcx!BexrxhR!k5k%ubLMmckfsZ0B8B8}BYc6UA(nYZw(YplQvLGSD1#WGwh|~jJK7w0LUQ& zg4QTbg%7MS{qcMdr8teZ341+0u$c1?jWV5ih{Do0ocRcypYjguR|jo_T^OZxK>SP3>E*0MD()HZ0*S*pg%ekl7S9|`@CuB!Qdtdum30)Yv1QBtiE)1l2Cf+YBg`5Gbkc1Ah zKdzjrx;P);yC`8nN-joxxR><1uc=85H?SYcXSk%41o4j-)~L1k=A|sH`?YO~Lz^5l zFe~3ajU3YFOl7a+M6xJOe=u>mWH+Qx;>)RGw<4mLeR2oG6{RRAOKI(qZ@dHr`GqMgK#x6QI1Sh&{0#*&NA9rMZ;w@{D7}UPnDym zD(F=MFAyIscSI{_ziGRE(pusUY;=rN(CE|N-S59iHmXa(Nsg?F$cFrC=j#>phN&@H zM$sxdTtu>R2+YElp9-VZ7$@-} zg2s&ze^o(4U$k(gzR``AgKIveqqB@UtEj(>`qvLt2gb_-;}!IZF(g(&dp;*jtME)` z1q}kN4XvAYM_}~1!`0whb8fPX7Y>aeDs_FQg3g*&SV6tYXu|Sj!$K`Er7araQ!L^2im~2dvM-~CLVb%+f0$S2TYy+JcBH}2v zoCZ^n7C2Rna;l1pDjIh-;#4)SS4>W@+b;h;I*WRqBrm>08rdx ztu$WN**5@M+z$mwn~=l@3p{l@cq{n#h4tMa0#E!+dXnRFn$md8_TqCe#(HC4gPuQY zWB=f$!j`paTc()>ea}eZYBu6f3IsVJPQ|BO%N14NPX` zy;25T1(sIk^}meMobQ2oiGUElLV18v;+Veh@J_RUcu9^_&zTsYMurM7{u$X5N;_m& zU4TKS9A^6?BV))v>si`5qlk>X@bIQ+21e=m5ChDh4EpsvhtbcQqLG3fa?1U*4|2*o zAzx>$cDyV0_nDvS`8im(&%Ex-kZE4E->?_#>ie3_lxJ?%TuTrT>b*vb?M5P(+mCZM z4?#`#fV)W?KujWbMG?q|;3dA6>^*rsDdfpL45$m@+NsW}O%RFd2PaHbql6KPy>%3Y zM)!{aM%;n|v_q)*_Kml15M4QYYqr!iR$&j<*rw0J?RRJH%v8g>%Hdrb;XT!Gv>c9Z zgb%EFpM=^?IpRYVc8tJx^poT>)xm$Weg^!SO{dR0P=loQ>Ydf~%NuR`A(MQX%yjzJ zX*I`OVSBX%wSuyz%=T2-on>}sY1bS2M99AW0yKd z9r@v*YZhTwsXHra9I3P!Wo%5{{kzznQb z>d~zFKq#$7>1xNo1eaiDwGuFTXpJ`|8w$1o#>QM`-ic|KLhfR_!e)wma940Hqf6}U zsbZMQo&jSPKQ%nJ6h9R!x$*_^%;A~murna+uVAvZV%^r|xR1E_suQ^Z3g zYtw{c8mh&MQlD&f&IjqqIl$Y8BtT)Oz4C8u@xNU<@tQ>>UxeVeQpTWr@-H5$i(T~dFy5S=ZC&tzZzVFKix z)ZZ+m3mn|2F2_OZ=ZcLl{eVIC>y*G!u|vyDy-XGu8XoolUm?W;6vYM_&2wrlfG>6#R#9!z7dyd04uW_% zs;&DAsL6g)nnO*NxYbc$Fv%|@i2UN$N%1`>G(!hz5-ppC-+U-y3u@S4Q)D`y~k^z=4y!hRfwZn zAao~G>poEH-SeRRfv?nku*OAdJ4Tgq_tVba4s7e?^q4O{3n=0(wp>zkpO?;X*A|5=M}A$9jp6~A5D$`KCnNck$9B&R;rCqxKVMgeh6(Q3n6n@RDvrqsB85t#__t*D+ zEdAS!f4%Wbf9YbP+&^DINt0!$pL6<(o7|p?Nma z1V5OFXA>DA9+!RbcqYd$r%9cS$Kkg;X|2Z-kMp@CG&@Oy>>)qL0#8346ZaN!i(v9G2Q%XDl7eIgFbRN_jHkL9BK9~>3Jk?Zj6#j-0TWU&DM)NB&Oq@N z^Tc`dbAQjxX~_HCe6<$nxOuA9bol0XYW}X9)6bk?SNF4_Gd5T2v)Ak%*N$gRP8au# zZFdbkJLGqbKI`ms9e+0Na`~PI?XE6$CW1fGh_Av+4xKBUmkvkIOKQ4WG?LtEW|_ES zO6pe4F_RDyE9qdC4xGwi0TVYsZq|NZ5}k+NiN)0qOn!`jiC-dnrA4@LA$J7I3Nc9r z8B5;Lke>`V-#~G zizaK8IG0O6fP!;kPI75ZVo83HLO@PwdS)KiOGcnhO~zZoKxu?5M6!q(DCeiia*I7a zJ|#anKK>S40azDU0wkK4lM^4mlHoJR8NZYulEwNd1qJcRaGUhw<1_OzOXB183Mzkb x*yQG?l;)(`6@fejvb$IuNPJ*sWMurv#Kg$*m4ks%;{mtA1un%#_970T901@3Lhk?o literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..16765eb0600e6c5ef3e159c664eb5370def1c6b3 GIT binary patch literal 10218 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 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 0000000000000000000000000000000000000000..7d88674faafb3b313b1f60903ab2bbfd1c54fc57 GIT binary patch literal 266 zcmX@j%ge<81VNVlnUz5LF^B^LOi;#WDIjAyLkdF_LkeRGQx0P;Qxp>;Lke>`V-#~G zizaK843}$OMq*xaYLS9-Voq{tPGU)ZkwQRDX?kWJ*GopAc1^}x5->FgB@h)w%s>Tx znk={2&r zyk0@&FAkgB{FKt1RJ$UO??Cn!ivx)d%#4hTADNgKS-x^GFzP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3fe33c0fa7817738b77027247e894851fbd34e45 GIT binary patch literal 7235 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 Iz^%mazlB;Li~s-t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4645273a7adb8efe4fb84dbacc098f6e79b6eeb GIT binary patch literal 260 zcmX@j%ge<81QRRzGxLG;V-N=hn4pZ$Qb5LZh7^V#wg}W z7ERVFN!PN}yb=Yc(qaYIip1Q4oK%H?oYM5nJiV8UK)srbw?x4zfC`}U5a}Xjpsb%J z%Psc!_>}zQ`1o6F1z>$(36N-FPELIMN`}uMr~FcdNEYj-f~<>ADlLvrg;=K_AD@|* zSrQ+wS5Wzj!zMRBr8Fnit_b8ekOPW^fy4)9Mn=Y)4DxpwG#_v)UEoq~WG~_XiUR-` CFh=eG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4010e5afce12ce519eea1681ed08faf1c51a4e5 GIT binary patch literal 10594 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 k%U4#H#r|zAW!e5)tIYy-GGyO)du*1b`Io 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 0000000000000000000000000000000000000000..38c171bab6406cade49fb5e4cb4508bb29ee1b48 GIT binary patch literal 263 zcmX@j%ge<81aFo5GYf$9V-N=hn4pZ$Qb5LZh7^V#wg}W z7ERVFWiI!`+*F02)Wnq3B87mQ()7$cg|z%41=mpB(2~rY%#up3myAGDG#PIRfRsX1 zLKGG;110=4S#Gh%$EV~c$H(7dD*&4SmH>$+=H$f3uVnZPa?dY$h-9&TI?$Z>BCwVE z@$s2?nI-Y@dIgogIBatBQ%ZAE?TSEN1KCGcq!MWMX1u`O3k-sQQ3g_5zn& KBYP1CP!0eWyhRoO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3c5b7f8b7b4568f899259474a7c0f3882946fc94 GIT binary patch literal 12020 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 BC`14N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ab5ec8e09fac3800cfbc3feeb71533ad09569ab1 GIT binary patch literal 257 zcmX@j%ge<81pBP}GqZs7V-N=hn4pZ$Qb5LZh7^V#wg}W z7ERVFaW40q{G`Mjg^;4eFkI&4@EQycTE2#X% xVUwGmQks)$R|N7D$nIisAn}2jk&*Eu6B8rLR}Kb7jR)Kc7q}D~*^4-UasY=DL#hA( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c46a757f1c50a3fa0133f0134d637b66c57cf8c0 GIT binary patch literal 12794 zcmcgydvF`adB4N+@FD>ce1I>34?Q4?kZ4Pmtv8>d5}6WB$&O(=m?Q2;f;s^7-BA<~ z(A9MO2h?#ZDauq-Y>$zoQ&Y7%Ryv)j+Rikpr+;MHnGO-aA#;^mrB!F5|7a;0r|KW+ zw|g%DJX(2lav=A1_jbSi_S@fn`|Y>;FCLGRfQS9}%kf{f6U2X^g!)*O$m73&$aR7t zA_PM+CXS4d@HTU%gehW5m?LHq=8ST+c+xWh&U3?h%@1exDxJ& zJK>3V5>=6^M0KP(;f;6`zK9Q(wR1Iz+DI*qQ=C6h7pcQ>2UnkHh&151lWR-_A_0;x z5yuI}^)|t{*}9L+GOv-Q1yjhA9(4|#?KvyPxwyCx>6Jj2tmb1WY`&CN~4 zlTIg22dD5yVOMbE)G&neadA4BOo{Qyc#Ia~siY8gK19SvLneu0Xpt4;3088TkYPEI z4v~`OG%ZfU+sd$S&aokfWFLDa&P=hQE;V!k8mg#uBkH z)`*?4MJUGpHW6_!6ogL30ila=Lg+^05b-c>2&)(mgw>3Xsd}4?c$sQQ`4+sP8mU$u z2W1#!Ueg_AqY$K%3?2x190ZXTh47QA@&Qa+d&7IednEUX#Oxd=#Lu(Cr^nN+WrWn> zMD+U3j0kCWOys$4VLCo3c2C8~5*b(^j^Oin1&ct`zA@xSN<2eEwcHFbqYABr$PA)V z@yysOG9byA7}G8DM+ya!5FKh6#&XMA(w;i`h$#YEw`rwJkkaS^FFy5){{BdblEi{7 zWKY+Pv*O&Wg7cs#Pl9mT2Uw5K)8LD=GmkuNECY^E{M_8xraXk;Q)MW z3Hk!ZCPzaK9`PnQQ2pp+oMR=MTuHJf&M|SGN6bogg|g&KEJW3KUD?EnvPpz?Msh~E z6wO2t!J9Cf1eTi=ki3IWg0pz83JRZ5h5Oxdc6fF{a>kQ!F-~*wG@EXdae{%s@-o1} z`uBDqDU3mIk$4bjU1{qr1fI!S-ldaG}%Y%3Ju27vt>ez#Z zmfT>mAp`~8YfUZis$Sa)sUp>JAL>@c z6rr}PLYCaDtXVG$(A79&Ea<0F0jOFQES*FzA&~PXl6aLM;kRJ9V1AXDCp#hjs+I~_ z)4r2{7ZuS6Here6`5JL1YCqK*3;;-v)2K69Qw5>G<6RWrQmbTqN$hsxKe8bY#^P%)5d`SI4@^ z=Bc|^7g*~0Abcad+*zm#uM=ipA9=g|Uh|d@Ubyi>ZtUGdR|ePojn_|JJ-O=Np7(EG zwtP(8q;3c9^cQvp9IV(-ipJZslUryhGjIL&G7&D zpCPzT=n4{WWS|NwP^n>fMpI%yMtOAgXN*Y|UGUYd*3sXL={-{hERkClb^Qkm!HRw* zxUz0Ql~qPn$d>kv)92X=9L*synwP-trHeU%X0fY zxA*diOD6z2Im?Ic@3;%iJ!@?pAEa)iKDU}1s@83U*SF;Rh289F`N9k}zChJJN5K=p z1!S+k&=)ZOW5C+iVK*M_Nm!anM_biQi)4m0(p>s-3Co%`>ScPzWV|G*ql|TkwjctJ z%EOrD-$%+K3-nPB@S|~M7B&5)1Z`x!Wm9ppXvr&(T+pte=wLZwoVK|P4wDejf9Cyry;ccF)ny~HqoR38sq{hQK&!0Q z)39tb&ounOJkwCAB4^PpA`Olo#;3LgSkWj!55sB+{cYI88nr(3(7g#(3~1@KN_eSN zbAgvewO!z4j~dtCzcnugwA5B{51uA%Bh67VU&Los9#v^WUz*fB=*wO;uD@r{idXS4 z{t~W?YrRlW1(H()Q}AM!IU7hRF7aeQFJ+QKht4m8mksOF%6FlB$~NS8M9;nIS!{#z3aSYFvMpQ1Te| zwM2uJ@Ytl*TddAh8}it!mH-~Be*+#zbl%ih2_<75tIJCKHZ#FfLcuiiI zVEUh&u3T6Q2_f1W^yl$S-QCGQ$>`q)072J_*5BVM~d& zrA}zckO2A**CPPjhoD z9t$3+#)pX9Xo%#Io&~px3EWlWROwdWiQ~`|n`AG{@<-A=Mt%$A!JCVR4{@m&%?U@s zTJcFBDRe;aGvb#Qi67OBEcuq{kJ+2-?c;aG3gM%7_ZGs(ztx+s8TlUpZMXlhsf&Ey z-bJPzax);ZbcZrnoqJ>S$M!yBSi?U) zoth7(lH5Wtv7o?SNPEhFnsy)-aG{52^gz&-GsydDJI&DxET5)cOik>@o>A+|XaL~z z!YoVwCrDqz077Z0BlZ6i#AV!;wTN1s_Ox0|VTd8^i^|0CGK9hExVFgja zU0N!mQ{q+swIrcVgfb$C-!C8HFYl`gfMhDkmiGctmcMFhZ13hhO;fm|Fd2T=UMf>~ zvtCYcu+(hi^!L-t&{iIKFeQ(`isRw}$Bs|4;Fm)xOmd>USvm%|l4>A604o@BQ@=>_ zaFiqPXnV&a&jF7do01bL&sluhAvvJ9eDEQ;Avzqtz%t{o_DK%FsdCcdgy^`)vtn!- zXUhjRJX&8RGdR8ZAdU%h3CSg+@IE8A&CV1q{l01rEJSrkTxKxEZ#?2*?1F+bOQUKB;DL$Tt&eEJb$MK<&AbF=N z@})W`taRZdEr{c?7LdGAo}J`bVOsUU^Dfk~J;uf1ER2CjDw%|`k~=EGWKv2=4mkzP zhP<*%)HW}lXxTvXqQfjoK3$sPCASzCITrej^PO@EeU)6ej4}>Z`~jqZ&UB)a=wc=v!d8$3v zo*P>xmwSs;&(CdyzpGHYHA_LPvGdwu*8QNa`P$dA&b9is)%sArKD69(XJVy3RH*OE zy4I)~xuvDoic|=vw&bZTIa98`NNr!E{Hs)3o@!f47pa{Oy!9&$+Y8}Dw&E0`Ql#4Nqn>;{g#wO0m_54zxO&sy(Q*MKdt#C*Wr;EFXPh3b*4?{K`tl zzC!bJ=s2Vij#1>cR;g`yYTL^8uNJA3I&0$UmfiU+yNlGG2Z1fQZOi^;Tdukg*i&`_ z)0L;XR<@ofQeP>l_`zE@-YQbz2aRpHmRqsgU4OIt`@0L_{*|7Al`VsX#v!2KL1#Fg zOCS|=db83sQlw6lQdqpP2!nGU$y1RG9y~0WW{??3rnc=`Pu6~~W$VheLxq;Zs7nFd zB{bfv)b>2JeYqOYutrs{QWyhWt4-bcrtTuO>p^o@?zKX5uaO*be^8|MmsQ%CZ`xU; zy6+=GO?j%RNVVwPG{C3N2RyjQDZG-~^Bh%>4Q{4rsXO$YrQv-Jgv6xhd_*Vnn`2gwteAm7r^&CbUI$K*hhhSU_ za4yvD%u;KVSBCdGK?QX(J{3j5JQd9OKWzI>8-PY9G7U51J_0bB8AU3n%MOhsMm!px zBGtTBU6=Ln$hB3r5kMLcpJ#M{Ke^Py4SQO&o2qy|Qnm)@?n4vWED*o$VQ zsg?S$Ma?&*d&|{wgTxi`jlU;|*MYM|)1rBk)-^A(&V0tim__Y)7`)z^$5AhXyx!Ut z$ApV}hDIMfz@$F`hLZOip#Z9TQNo6!#H8PB^Grb_oHFIqN{XGxt! zYsSh{qirL<6TJ1B-&B{rh&Jc=nbG`wY7RlnX#O%iPn8%+B;Nq)-rs>bYXsp?K<`rYcbrQ=6nT378mvl4R=O)W&#j z8qbdU5(H@HI(l1I$anq$w)mz{L%RMY*@#m2sX=V8h2#JFt6%+U6z}>a3%U^WgzSm` z67u+C&@s3c0agpkOZKzLbIH_vlHZ4OMX*`Hu0>`?+8lmnGHnT-9vX~$w*yeH@8B8u z@(^`dGQ-ss$qaVCWRvZehe&QHk z?tGywT=4IHOn4o~$xDOTzU-T8ZttqQE$?nyb#KeNx8>f1Rki2v9r_3C-?Ddye>hM$ za(ZQKd}Zv+%8|2$o|lX6SH85y$PeR%BVQ}@yjpZee(iz}0-@!xkI&vbdwc(#zCzED zJHpDYfxCNGc8(MRC$e8z^VeM;x;nJ9J=a_CZ_N&^Rr{B$fArRxw`tY8Bk$dDtLEdj zn{BJ1qxsO$Prb+1eDzCR-)!CR`A9x=cy+;g~UXB#q&);wB z%*}l}dG+j_eRl`{?(p}w{%|@wY)}{pZ?e!c^ket%=j}+$8`oSLj3HigMvuyE3gEoB zwUBQPu7Ak4gO=Pm?)K2#y;-mwN3e~CETK=SEx&IWc{CY;J_+-GjQZm~1sZ}*2493N zDnA7|1d+XV>)S4-!pya6#$&eXS#+0vZ!pcgu6(Dx{6CujsSr zK9murA-G7u4x+9VZ6~jduKIW7{ksbO@LGKf`m1YNYiRr2W%D_+)^#uJQ!cwN0Xon& zVrdAj*!|R11N!FjsY|C;-5q&%M{Zxy9a3hNx<4zMPhcf$j|L?QLkVdY4s2+~3|6$s z=#&Xy0$?$+?p3P*>^=+s23SOBIjeZ!Hk+c;0A|J|w0j?mwvxSpaar9WQK{O&-m#y9 z?aT#IfP2=EqEHI%WDqm13T7agv1V);JKVw0bm@W#;N(a*oTkAXP~a4tCs z*pL}b_w9InzB}}~(7og6fpc*VE@)GPm)_>fI9@!-Z6SegfyoAbo+(NixC4fKrziYTsoz}ZD%wa1T?D^n?t>TRRd>d z0=VO#zfpx=dYh3p+gR=qP!ryQ;3DzZMmTEVhE?q=WY)gst-0>F>VTzs|8oDwFW!8y z;60GF{?z3KbYFk%>T5aQwXegnS(>=#4=io{p!-I5j(!)6lTdc(etk>MdNuW<`eV!W zw_#a6mOZWje`z>3ygYP!|EKPQ>mI`G!|MhdEe*qsYAPB{f*&m!g^Oj;M2eXMKU@@C zdC@oLXioWJk4BkP46-dKL$V`V5Y7ic8_bE|MU-3&E5!KtEIKrhY||<69J|1SEh^u4 zl=TFUl6YC9Q4FmCcxd?VLhL4iS&~%)+U4*lKpPqXE%t8{Ke6n(?`pm{xK>qn@%Wmj z`Qq?eP4C68;@BgL!`k$y-eIkKRByAkJo3IpTI(Mjv(#CSlaIn?Yw(d1g2zrT1YOnE zLvmB7flxF5=kUU}@UYV35za9#rn&fpd`Z~_?t4ylg$nY$e~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eda85c67ad24d188adb4135300b405887eaf7c7a GIT binary patch literal 210 zcmX@j%ge<81lo`KGd+RyV-N=hn4pZ$VnD`ph7^Vr#vF!R#wbQch7_iB#weyrW=)ot zj6g|E##=(3xdr(}C9V}9G9afkJu|O}87Sqa$$X2g0Kvb-9v`2WlM^4mlHoJR{9lR? z$zuIXumSO@U=#G?<1_OzOXB183Mzkb*yQG?l;)(`6>$P}fGjK)01_XV85tSxGN?V^ PmcPKI(8ylI4ip3c+%Yv{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5636354d2e12de4f329de4a471d9793cdb6d6c5c GIT binary patch literal 16792 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 h-tldkK1R;Lke>`V-#~G zizaK86qjdSS!!NMeo>`@Z(?3zdTNnEKu&3TW**l|MxbU*##>@=6;K5b`66bZw4Wx+ zE%x~Ml>FrQ_*-lRV2xl2kZ58~PJH}IhR-0!{8EKT7VBri?2FHZ*ry*KpP83g5+AQu zQ2C3)CO1E&G$+-r2;@7E3yQ^o#0O?ZM#hg!OpGjFIT#qVA8;#Q;8JO1FX8~o0RVEa BN1Ffu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b8026391ad576336fd07affa676ef27708f0910 GIT binary patch literal 11293 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 le@(!(D2g 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 0000000000000000000000000000000000000000..354baac993442ac6698de0fd87f2e7f46f6e0d0f GIT binary patch literal 251 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 rea=LG#e}|QS(p23#*Dti)Ng`}tMk8@17G#wg}W z7ERVFF)rWCyv)3G1&`F6g47~~fSl6w%sj4_j6jW=jJJd!vJgp#ToE%+%1@K!7JGbr zN`7*D{4KTuuqLnsNHj4gCq8~9!)K5qeknpEi}iEC_QYp^?a_~q2Xaf|VdxBLYzg+}%w4xk(WcvV7W literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59715c9252002a6b401020e049c217bba1e75704 GIT binary patch literal 11484 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 Dg-o|a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b938697bfd770fd8da00a7cddb325d62ded82c26 GIT binary patch literal 260 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 UqTn9UwAD1ScI-D4{u1*44W6i)MF0Q* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c2c6fda758375d663dac6e456e9cdb0d04d21687 GIT binary patch literal 266 zcmX@j%ge<81QrT?nZ-c*F^B^LOi;#WDIjAyLkdF_LkeRGQx0P;Qxp>;Lke>`V-#~G zizaK83YTAMMQO1@aB5;va)v@cPHB2(oC%vKQb{fvV7%WU{rg+Eq8%S LzLC9%11JXov{yzt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..804f0c59db36b0b25632560fc3a291198f4b34b7 GIT binary patch literal 20301 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 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 0000000000000000000000000000000000000000..2f812d32ad7bac5b181ab73fee9d999022b9ae49 GIT binary patch literal 259 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61868951e7f2ff53eb20e8d7d59cd14e0e2db373 GIT binary patch literal 343 zcmX@j%ge<81dHzWXYL2mk3k$5V1hC}O92_v8B!Rc7*ZHhm~t3%nWC5&8B&8T|Oi4Zf&Gg9*uGD}i(i@AzY6O%I(N)n4e(h${p zTrU}cj?!ejB>=V=Y%#>zB4(h3pC-#K_W1ae{N(ufTWkej_kblpqKP><@$oAeK7+jX zO93KTtPeIAC;*2lM)o2Opd0`noMWB< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d60589881acf498e114c9b06b7b2d9f413b97c1 GIT binary patch literal 35306 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07a8211913f2f616a3142d2cd7a75ce68bc5dbe7 GIT binary patch literal 269 zcmX@j%ge<81ewwOnPouwF^B^LOi;#WDIjAyLkdF_LkeRGQx0P;Qxp>;Lke>`V-#~G zizaK8G*>`Tep+gAab|v=LU3|oUS4XELO@PwdS)KiOGcn>O~zZ|2qjPz5CuidKzTn+ zmRs!c@hSPq@$t9V3cxzS5+KpUoSgXhl?OA09xxl5`$X>(&lmh^^ ClSvo= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..25d886312fb1deff5cbd611d40a1b886737ffb3a GIT binary patch literal 12733 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 F{vVJmys7{I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d73199cba09e4495108c9d1b982b66a467ec9f6d GIT binary patch literal 340 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 N``F(b@J-tM{{bOfg8={l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..03633ebf3ab5261ee795092743384388d8f581ec GIT binary patch literal 240 zcmX@j%ge<81pBP}Goyg?V-N=hn4pZ$Qb5LZh7^V#wg}W z7ERVFL9XD`l9J54^kRj8oYM5nJg%3FKs}m_xA}zQ`1o6F z1z-(e36N-FPELIMN`}uMyMM_*B#ZTnp~maS$7kkcmc+;F6;%G>u*uC&Da}c>D+2ie lWMQ#5kodsN$jJDSiHVWrD+dFk@&j(^3tTdd>_r?vIRLUQJ$C>A literal 0 HcmV?d00001 diff --git a/plugins/settings/__pycache__/plugin.cpython-312.pyc b/plugins/settings/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9595ddf8b9e7d16b00ca8a12e11c37ff41c13c73 GIT binary patch literal 52393 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

h!bZ&wIWWhzP8 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 C|6=C= literal 0 HcmV?d00001 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("
    ") + + for plugin_id, deps in sorted(self.plugin_deps.items()): + if deps: + plugin_class = all_plugins.get(plugin_id) + if plugin_class: + name = plugin_class.name + dep_names = [d.split('.')[-1].replace('_', ' ').title() for d in deps] + html.append(f"
  • {name} requires: {', '.join(dep_names)}
  • ") + + html.append("
") + html.append("
") + + # Plugins required by others + html.append("

⚠️ Plugins Required by Others

") + html.append("
    ") + + for plugin_id, dependents in sorted(self.plugin_dependents.items()): + if dependents: + plugin_class = all_plugins.get(plugin_id) + if plugin_class: + name = plugin_class.name + dep_names = [d.split('.')[-1].replace('_', ' ').title() for d in dependents] + html.append(f"
  • {name} is required by: {', '.join(dep_names)}
  • ") + + html.append("
") + html.append("
") + + # Dependency chain visualization + html.append("

🔄 Dependency Chains

") + html.append("
    ") + + for plugin_id, plugin_class in sorted(all_plugins.items(), key=lambda x: x[1].name): + chain = self._get_dependency_chain(plugin_id) + if len(chain) > 1: + chain_names = [all_plugins.get(pid, type('obj', (object,), {'name': pid})) .name for pid in chain] + html.append(f"
  • {' → '.join(chain_names)}
  • ") + + 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 0000000000000000000000000000000000000000..fcdbc3fa1c9a9c9040e3825a1ac3bba94eb04426 GIT binary patch literal 269 zcmX@j%ge<81VL*3nPouwF^B^LOi;#WDIjAyLkdF_LkeRGQx0P;Qxp>;Lke>`V-#~G zizaK8DpzoJW=@VmaB^Z^UTTp-Ku&3TW}ZS?evyJ}sBUOUW=>{FCD%(vpgEe1w}ilo zp=u#YiYY)L=$sz;^S8`dHF|vH+U|>{#z%74) MOQDgyhyy4G0L{}!ga7~l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..04f1268b93e952339728f2c66dbad5b6db9883a2 GIT binary patch literal 59667 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 BN)-SA literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3e51f904d13c743cb8fedc3e8ede83c418234edf GIT binary patch literal 284 zcmX@j%ge<81W77=nXN$jF^B^LOi;#WDIjAyLkdF_LkeRGQx0P;Qxp>;Lke>`V-#~G zizaK87FTdVeo1CprGj&QUP)1YPEKl(LO@PwdS;$NT7Hp&Yp8B$NoG!FNhQ}yMxbGu zjJL$0IuIHl>Wi3x@_w2ux7g$3Q}UDJ<8QGQfDHmmfJ75>a^mAxGJFQP?w1-wvRJ_r?vIRKzVPMH7z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e8888c307d745eec254f92850783c2584b76bd39 GIT binary patch literal 21157 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 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 0000000000000000000000000000000000000000..6fa290806ff4ad1969549e540043aa133c32c1f0 GIT binary patch literal 242 zcmX@j%ge<81VNVlnbAP{F^B^LOi;#WDIjAyLkdF_LkeRGQx0P;Qxp>;Lke>`V-#~G zizaK85LZZmLQrX5UTTp-Ku&3TW**l|MxZ85##?+L0T2m@co8#D*iVz?7JGbrN`7*D z{4KTuum-RMNHj4gCq8~9!)K7?zhohj#rh=$@kLJZxAX-rnMU>^4xk(Wd;~o* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ba425336f2e8b9ea3f8c7072d23d17aa73cf5d71 GIT binary patch literal 9885 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 Pt#pA)xskny11JXoJeW(G literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cafa0f9ace747fc2f18a9452fdae9dd449c77312 GIT binary patch literal 26627 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 literal 0 HcmV?d00001 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()