EU-Utility/docs/PLUGIN_DEVELOPMENT.md

1122 lines
26 KiB
Markdown

# EU-Utility Plugin Development Guide
> Complete guide for creating custom plugins for EU-Utility
>
> **Version:** 2.0
> **Last Updated:** 2025-02-14
---
## Table of Contents
1. [Introduction](#introduction)
2. [Getting Started](#getting-started)
3. [Plugin Structure](#plugin-structure)
4. [BasePlugin API](#baseplugin-api)
5. [Creating Your First Plugin](#creating-your-first-plugin)
6. [UI Development](#ui-development)
7. [Using Core Services](#using-core-services)
8. [Event System](#event-system)
9. [Background Tasks](#background-tasks)
10. [Nexus API Integration](#nexus-api-integration)
11. [Plugin Examples](#plugin-examples)
12. [Best Practices](#best-practices)
13. [Publishing Plugins](#publishing-plugins)
---
## Introduction
EU-Utility uses a **modular plugin architecture** that allows anyone to extend its functionality. Plugins are Python classes that inherit from `BasePlugin` and implement specific methods.
### What You Can Build
- **Calculators** - DPP, crafting, markup calculators
- **Trackers** - Loot, skills, missions, globals
- **Integrations** - Discord, spreadsheets, external APIs
- **Tools** - OCR scanners, data exporters, analyzers
- **Widgets** - Dashboard widgets, overlay elements
### Prerequisites
- Python 3.11+
- PyQt6 knowledge (for UI)
- Basic understanding of EU game mechanics
---
## Getting Started
### Plugin Location
Plugins are stored in the `plugins/` directory:
```
plugins/
├── my_plugin/ # Your plugin folder
│ ├── __init__.py # Makes it a Python package
│ └── plugin.py # Main plugin code
└── base_plugin.py # Base class (don't modify)
```
### Minimal Plugin Template
```python
# plugins/my_plugin/plugin.py
from plugins.base_plugin import BasePlugin
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel
class MyPlugin(BasePlugin):
"""My first EU-Utility plugin."""
# Required metadata
name = "My Plugin"
version = "1.0.0"
author = "Your Name"
description = "What my plugin does"
# Optional settings
hotkey = "ctrl+shift+y" # Global hotkey
enabled = True
def initialize(self):
"""Called when plugin is loaded."""
print(f"[{self.name}] Initialized!")
def get_ui(self):
"""Return the plugin's UI widget."""
widget = QWidget()
layout = QVBoxLayout(widget)
label = QLabel(f"Hello from {self.name}!")
layout.addWidget(label)
return widget
def on_hotkey(self):
"""Called when hotkey is pressed."""
print(f"[{self.name}] Hotkey pressed!")
def shutdown(self):
"""Called when app is closing."""
print(f"[{self.name}] Shutting down...")
```
### Plugin Registration
Create `__init__.py`:
```python
# plugins/my_plugin/__init__.py
from .plugin import MyPlugin
__all__ = ['MyPlugin']
```
---
## Plugin Structure
### Required Attributes
| Attribute | Type | Description |
|-----------|------|-------------|
| `name` | str | Display name |
| `version` | str | Version string |
| `author` | str | Your name/handle |
| `description` | str | Short description |
### Optional Attributes
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `hotkey` | str | None | Global hotkey |
| `enabled` | bool | True | Start enabled |
| `icon` | str | None | Icon path/emoji |
### Required Methods
| Method | Purpose |
|--------|---------|
| `initialize()` | Setup plugin |
| `get_ui()` | Return QWidget |
### Optional Methods
| Method | Purpose |
|--------|---------|
| `on_show()` | Overlay became visible |
| `on_hide()` | Overlay hidden |
| `on_hotkey()` | Hotkey pressed |
| `shutdown()` | Cleanup on exit |
---
## BasePlugin API
### Configuration Methods
```python
# Get config value with default
value = self.get_config('key', default_value)
# Set config value
self.set_config('key', value)
```
### OCR Service
```python
# Capture screen and OCR
result = self.ocr_capture()
# Returns: {'text': str, 'confidence': float, 'raw_results': list}
# Capture specific region
result = self.ocr_capture(region=(x, y, width, height))
```
### Screenshot Service
```python
# Capture full screen
screenshot = self.capture_screen(full_screen=True)
# Capture region
region = self.capture_region(x, y, width, height)
# Get last screenshot
last = self.get_last_screenshot()
```
### Log Service
```python
# Read recent log lines
lines = self.read_log(lines=50)
# Read with filter
lines = self.read_log(lines=100, filter_text="loot")
```
### Shared Data
```python
# Set data for other plugins
self.set_shared_data('my_key', data)
# Get data from other plugins
data = self.get_shared_data('other_key', default=None)
```
### Utility Methods
```python
# Format currency
ped_text = self.format_ped(123.45) # "123.45 PED"
pec_text = self.format_pec(50) # "50 PEC"
# Calculate DPP
dpp = self.calculate_dpp(damage=45.0, ammo=100, decay=2.5)
# Calculate markup
markup = self.calculate_markup(price=150.0, tt=100.0) # 150.0%
```
### Audio Service
```python
# Play sounds
self.play_sound('hof') # Predefined sounds
self.play_sound('skill_gain')
self.play_sound('alert')
self.play_sound('/path/to/custom.wav')
# Volume control
self.set_volume(0.8) # 0.0 to 1.0
volume = self.get_volume()
self.mute()
self.unmute()
```
---
## Creating Your First Plugin
### Step-by-Step Tutorial
Let's create a simple **TT Value Calculator** plugin.
#### Step 1: Create Plugin Directory
```bash
mkdir plugins/tt_calculator
touch plugins/tt_calculator/__init__.py
touch plugins/tt_calculator/plugin.py
```
#### Step 2: Create `__init__.py`
```python
from .plugin import TTValuePlugin
__all__ = ['TTValuePlugin']
```
#### Step 3: Create `plugin.py`
```python
"""
EU-Utility - TT Value Calculator Plugin
Calculate total TT value from a list of items.
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QTextEdit
)
from plugins.base_plugin import BasePlugin
class TTValuePlugin(BasePlugin):
"""Calculate total TT value of items."""
name = "TT Calculator"
version = "1.0.0"
author = "Your Name"
description = "Calculate total TT value from item list"
hotkey = "ctrl+shift+t"
def initialize(self):
"""Setup the plugin."""
self.items = []
def get_ui(self):
"""Create the plugin UI."""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setSpacing(15)
# Title
title = QLabel("TT Value Calculator")
title.setStyleSheet("""
color: #4a9eff;
font-size: 18px;
font-weight: bold;
""")
layout.addWidget(title)
# Instructions
info = QLabel("Enter items (format: Name - TT Value):")
info.setStyleSheet("color: rgba(255,255,255,150);")
layout.addWidget(info)
# Input area
self.input_text = QTextEdit()
self.input_text.setPlaceholderText(
"Example:\n"
"Animal Oil - 0.05\n"
"Lysterium Stone - 0.01\n"
"Weapon - 45.50"
)
self.input_text.setStyleSheet("""
QTextEdit {
background-color: #2a2a2a;
color: white;
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
}
""")
layout.addWidget(self.input_text)
# Calculate button
calc_btn = QPushButton("Calculate Total")
calc_btn.setStyleSheet("""
QPushButton {
background-color: #4caf50;
color: white;
padding: 12px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #5cbf60;
}
""")
calc_btn.clicked.connect(self._calculate)
layout.addWidget(calc_btn)
# Result
self.result_label = QLabel("Total: 0.00 PED")
self.result_label.setStyleSheet("""
color: #ffc107;
font-size: 20px;
font-weight: bold;
""")
layout.addWidget(self.result_label)
layout.addStretch()
return widget
def _calculate(self):
"""Parse input and calculate total."""
text = self.input_text.toPlainText()
lines = text.strip().split('\n')
total = 0.0
item_count = 0
for line in lines:
line = line.strip()
if not line:
continue
# Try to extract number from line
parts = line.split('-')
if len(parts) >= 2:
try:
value = float(parts[-1].strip().replace('PED', '').replace('PEC', ''))
total += value
item_count += 1
except ValueError:
pass
self.result_label.setText(
f"Items: {item_count} | Total: {total:.2f} PED"
)
def on_hotkey(self):
"""Focus input when hotkey pressed."""
self.input_text.setFocus()
```
#### Step 4: Test Your Plugin
1. Restart EU-Utility
2. Your plugin should appear in the plugin list
3. Press `Ctrl + Shift + T` to open it
---
## UI Development
### Styling Guidelines
EU-Utility uses a dark theme. Follow these conventions:
```python
# Background colors
BG_DARK = "#1a1a1a" # Main background
BG_PANEL = "#2a2a2a" # Panel/card background
BG_INPUT = "#333333" # Input fields
# Accent colors
ACCENT_BLUE = "#4a9eff" # Primary accent
ACCENT_GREEN = "#4caf50" # Success
ACCENT_ORANGE = "#ff8c42" # Warning/Highlight
ACCENT_RED = "#f44336" # Error
# Text colors
TEXT_WHITE = "#ffffff"
TEXT_MUTED = "rgba(255,255,255,150)"
```
### Common Widget Patterns
#### Card/Panel
```python
card = QFrame()
card.setStyleSheet("""
QFrame {
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
}
""")
```
#### Button
```python
btn = QPushButton("Click Me")
btn.setStyleSheet("""
QPushButton {
background-color: #4a9eff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #5aafff;
}
QPushButton:pressed {
background-color: #3a8eef;
}
""")
```
#### Input Field
```python
input_field = QLineEdit()
input_field.setStyleSheet("""
QLineEdit {
background-color: #333;
color: white;
padding: 8px;
border: 2px solid #555;
border-radius: 4px;
}
QLineEdit:focus {
border-color: #4a9eff;
}
""")
```
#### Progress Bar
```python
progress = QProgressBar()
progress.setStyleSheet("""
QProgressBar {
background-color: #333;
border: none;
border-radius: 4px;
height: 8px;
}
QProgressBar::chunk {
background-color: #4a9eff;
border-radius: 4px;
}
""")
```
### Layout Best Practices
```python
# Use consistent spacing
layout.setSpacing(10)
layout.setContentsMargins(15, 15, 15, 15)
# Add stretch at the end
layout.addStretch()
# Use QGroupBox for sections
group = QGroupBox("Section Title")
group.setStyleSheet("""
QGroupBox {
color: rgba(255,255,255,200);
border: 1px solid #444;
border-radius: 6px;
margin-top: 10px;
font-weight: bold;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
}
""")
```
---
## Using Core Services
### HTTP Client
Make API requests with caching:
```python
def fetch_data(self):
# Use the built-in http_get method
response = self.http_get(
"https://api.example.com/data",
cache_ttl=300, # Cache for 5 minutes
headers={'Accept': 'application/json'}
)
if response:
return response.get('json')
return None
```
### Window Manager (Windows)
```python
# Check if EU window exists
if self.api.services.get('window'):
window_manager = self.api.services['window']
# Find EU window
eu_window = window_manager.find_eu_window()
if eu_window:
print(f"EU window: {eu_window.width}x{eu_window.height}")
# Check if focused
if window_manager.is_eu_focused():
print("EU is active!")
```
### Notifications
```python
# Show notification
self.api.show_notification(
title="Skill Gain!",
message="Rifle increased to 25.5",
duration_ms=3000
)
```
### Clipboard
```python
# Copy to clipboard
self.api.copy_to_clipboard("Text to copy")
# Paste from clipboard
text = self.api.paste_from_clipboard()
```
---
## Event System
### Publishing Events
```python
from core.event_bus import SkillGainEvent, LootEvent
# Publish a skill gain
self.publish_typed(SkillGainEvent(
skill_name="Rifle",
skill_value=25.5,
gain_amount=0.01
))
# Publish loot event
self.publish_typed(LootEvent(
mob_name="Daikiba",
items=[{"name": "Animal Oil", "value": 0.05}],
total_tt_value=0.05
))
```
### Subscribing to Events
```python
from core.event_bus import SkillGainEvent, LootEvent, DamageEvent
class MyPlugin(BasePlugin):
def initialize(self):
# Subscribe to skill gains
self.sub_id = self.subscribe_typed(
SkillGainEvent,
self.on_skill_gain,
replay_last=10 # Get last 10 events
)
def on_skill_gain(self, event: SkillGainEvent):
print(f"Skill gained: {event.skill_name} = {event.skill_value}")
def shutdown(self):
# Unsubscribe
self.unsubscribe_typed(self.sub_id)
```
### Event Filtering
```python
# Subscribe to high damage only
self.subscribe_typed(
DamageEvent,
self.on_big_hit,
min_damage=100
)
# Subscribe to specific mob loot
self.subscribe_typed(
LootEvent,
self.on_dragon_loot,
mob_types=["Dragon", "Drake"]
)
# Subscribe to specific skills
self.subscribe_typed(
SkillGainEvent,
self.on_combat_skill,
skill_names=["Rifle", "Pistol", "Melee"]
)
```
### Available Event Types
| Event | Attributes |
|-------|------------|
| `SkillGainEvent` | skill_name, skill_value, gain_amount |
| `LootEvent` | mob_name, items, total_tt_value, position |
| `DamageEvent` | damage_amount, damage_type, is_critical, target_name |
| `GlobalEvent` | player_name, achievement_type, value, item_name |
| `ChatEvent` | channel, sender, message |
| `EconomyEvent` | transaction_type, amount, currency, description |
| `SystemEvent` | message, severity |
---
## Background Tasks
### Running Tasks in Background
```python
def heavy_calculation(data):
# This runs in a background thread
result = process_large_dataset(data)
return result
# Run in background
task_id = self.run_in_background(
heavy_calculation,
large_dataset,
priority='high', # 'high', 'normal', 'low'
on_complete=self.on_done, # Called with result
on_error=self.on_error # Called with exception
)
```
### Scheduling Tasks
```python
# One-time delayed task
task_id = self.schedule_task(
delay_ms=5000, # 5 seconds
func=lambda: print("Delayed!"),
on_complete=lambda _: print("Done")
)
# Periodic task
task_id = self.schedule_task(
delay_ms=0, # Start immediately
func=self.refresh_data,
periodic=True,
interval_ms=30000, # Every 30 seconds
on_complete=lambda data: self.update_ui(data)
)
# Cancel task
self.cancel_task(task_id)
```
### Thread-Safe UI Updates
```python
def initialize(self):
# Connect signals once
self.connect_task_signals(
on_completed=self._on_task_done,
on_failed=self._on_task_error
)
def _on_task_done(self, task_id, result):
# This runs in main thread - safe to update UI!
self.status_label.setText(f"Complete: {result}")
def _on_task_error(self, task_id, error):
self.status_label.setText(f"Error: {error}")
```
---
## Nexus API Integration
### Searching
```python
# Search for items
results = self.nexus_search("ArMatrix", entity_type="items")
# Search for mobs
mobs = self.nexus_search("Atrox", entity_type="mobs")
# Search for locations
locations = self.nexus_search("Fort", entity_type="locations")
```
### Getting Item Details
```python
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')}")
```
### Getting Market Data
```python
market = self.nexus_get_market_data("armatrix_lp-35")
if market:
print(f"Current markup: {market['current_markup']:.1f}%")
print(f"24h Volume: {market['volume_24h']}")
# Access order book
for buy in market.get('buy_orders', [])[:5]:
print(f"Buy: {buy['price']} PED x {buy['quantity']}")
```
### Entity Types
Valid entity types for `nexus_search()`:
- `items`, `weapons`, `armors`
- `mobs`, `pets`
- `blueprints`, `materials`
- `locations`, `teleporters`, `shops`, `vendors`, `planets`, `areas`
- `skills`
- `enhancers`, `medicaltools`, `finders`, `excavators`, `refiners`
- `vehicles`, `decorations`, `furniture`
- `storagecontainers`, `strongboxes`
---
## Plugin Examples
### Example 1: Simple Counter Plugin
```python
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from plugins.base_plugin import BasePlugin
class CounterPlugin(BasePlugin):
"""A simple click counter."""
name = "Click Counter"
version = "1.0.0"
author = "Tutorial"
description = "Counts button clicks"
hotkey = "ctrl+shift+k"
def initialize(self):
self.count = self.get_config('count', 0)
def get_ui(self):
widget = QWidget()
layout = QVBoxLayout(widget)
self.label = QLabel(f"Count: {self.count}")
self.label.setStyleSheet("font-size: 24px; color: #4a9eff;")
layout.addWidget(self.label)
btn = QPushButton("Click Me!")
btn.clicked.connect(self._increment)
layout.addWidget(btn)
return widget
def _increment(self):
self.count += 1
self.set_config('count', self.count)
self.label.setText(f"Count: {self.count}")
```
### Example 2: Price Monitor Plugin
```python
from PyQt6.QtWidgets import *
from plugins.base_plugin import BasePlugin
from core.event_bus import LootEvent
class PriceMonitorPlugin(BasePlugin):
"""Monitor item prices from loot."""
name = "Price Monitor"
version = "1.0.0"
author = "Tutorial"
description = "Track item prices from loot"
def initialize(self):
self.prices = {}
# Subscribe to loot events
self.sub_id = self.subscribe_typed(
LootEvent,
self.on_loot,
replay_last=50
)
def get_ui(self):
widget = QWidget()
layout = QVBoxLayout(widget)
title = QLabel("Price Monitor")
title.setStyleSheet("font-size: 18px; color: #4a9eff;")
layout.addWidget(title)
self.table = QTableWidget()
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(["Item", "TT", "Count"])
layout.addWidget(self.table)
self._update_table()
return widget
def on_loot(self, event):
for item in event.items:
name = item['name']
value = item['value']
if name not in self.prices:
self.prices[name] = {'tt': value, 'count': 0}
self.prices[name]['count'] += 1
self._update_table()
def _update_table(self):
self.table.setRowCount(len(self.prices))
for i, (name, data) in enumerate(sorted(self.prices.items())):
self.table.setItem(i, 0, QTableWidgetItem(name))
self.table.setItem(i, 1, QTableWidgetItem(f"{data['tt']:.2f}"))
self.table.setItem(i, 2, QTableWidgetItem(str(data['count'])))
def shutdown(self):
self.unsubscribe_typed(self.sub_id)
```
### Example 3: Weapon Comparator
```python
from decimal import Decimal
from PyQt6.QtWidgets import *
from plugins.base_plugin import BasePlugin
class WeaponComparatorPlugin(BasePlugin):
"""Compare weapons from Nexus."""
name = "Weapon Comparator"
version = "1.0.0"
author = "Tutorial"
description = "Compare weapon stats"
def initialize(self):
self.weapons = []
def get_ui(self):
widget = QWidget()
layout = QVBoxLayout(widget)
# Search
search_layout = QHBoxLayout()
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search weapons...")
search_layout.addWidget(self.search_input)
search_btn = QPushButton("Search")
search_btn.clicked.connect(self._search)
search_layout.addWidget(search_btn)
layout.addLayout(search_layout)
# Results
self.results = QTableWidget()
self.results.setColumnCount(5)
self.results.setHorizontalHeaderLabels(
["Name", "Damage", "Range", "DPP", "Add"]
)
layout.addWidget(self.results)
# Comparison
self.compare_table = QTableWidget()
self.compare_table.setColumnCount(4)
self.compare_table.setHorizontalHeaderLabels(
["Name", "Damage", "DPP", "Remove"]
)
layout.addWidget(QLabel("Comparison:"))
layout.addWidget(self.compare_table)
return widget
def _search(self):
query = self.search_input.text()
results = self.nexus_search(query, entity_type="weapons")
self.results.setRowCount(len(results))
for i, weapon in enumerate(results):
self.results.setItem(i, 0, QTableWidgetItem(weapon['name']))
# Get details for full stats
details = self.nexus_get_item_details(weapon['id'])
if details:
dmg = details.get('damage', 0)
range_val = details.get('range', 0)
dpp = self._calculate_dpp(details)
self.results.setItem(i, 1, QTableWidgetItem(str(dmg)))
self.results.setItem(i, 2, QTableWidgetItem(f"{range_val}m"))
self.results.setItem(i, 3, QTableWidgetItem(f"{dpp:.2f}"))
add_btn = QPushButton("Add")
add_btn.clicked.connect(lambda _, d=details: self._add_to_compare(d))
self.results.setCellWidget(i, 4, add_btn)
def _calculate_dpp(self, details):
damage = Decimal(str(details.get('damage', 0)))
decay = Decimal(str(details.get('decay', 0)))
ammo = Decimal(str(details.get('ammo_consumption', 0)))
ammo_cost = ammo * Decimal('0.01')
total_cost = ammo_cost + decay
if total_cost > 0:
return damage / (total_cost / Decimal('100'))
return Decimal('0')
def _add_to_compare(self, details):
self.weapons.append(details)
self._update_comparison()
def _update_comparison(self):
self.compare_table.setRowCount(len(self.weapons))
for i, w in enumerate(self.weapons):
self.compare_table.setItem(i, 0, QTableWidgetItem(w['name']))
self.compare_table.setItem(i, 1, QTableWidgetItem(str(w.get('damage', 0))))
self.compare_table.setItem(i, 2, QTableWidgetItem(f"{self._calculate_dpp(w):.2f}"))
```
---
## Best Practices
### Do's
**Use background tasks** for heavy operations
**Unsubscribe from events** in `shutdown()`
**Save data** to disk for persistence
**Handle errors gracefully**
**Use the API** instead of reinventing
**Follow styling conventions**
**Add docstrings** to your plugin
### Don'ts
**Block the main thread** with long operations
**Forget to cleanup** resources
**Hardcode paths** - use relative paths
**Ignore errors** - users need feedback
**Access game files directly** - use services
### Code Style
```python
"""
EU-Utility - Plugin Name
Short description of what this plugin does.
Features:
- Feature 1
- Feature 2
"""
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import *
from plugins.base_plugin import BasePlugin
class MyPlugin(BasePlugin):
"""
Detailed plugin description.
Attributes:
name: Display name
version: Semantic version
author: Creator name
description: Short description
"""
name = "My Plugin"
version = "1.0.0"
author = "Your Name"
description = "What it does"
def initialize(self) -> None:
"""Initialize plugin state."""
self.data = self.get_config('data', [])
def get_ui(self) -> QWidget:
"""Create and return plugin UI."""
# Implementation
pass
def shutdown(self) -> None:
"""Cleanup resources."""
self.set_config('data', self.data)
super().shutdown()
```
---
## Publishing Plugins
### Plugin Package Structure
```
my_plugin/
├── __init__.py
├── plugin.py
├── README.md
├── requirements.txt # Optional: extra dependencies
└── assets/ # Optional: icons, sounds
└── icon.png
```
### README Template
```markdown
# My Plugin
Description of your plugin.
## Installation
1. Copy `my_plugin` folder to `plugins/`
2. Restart EU-Utility
## Usage
Press `Ctrl+Shift+Y` to open.
## Features
- Feature 1
- Feature 2
## Changelog
### v1.0.0
- Initial release
```
### Sharing
1. **Zip your plugin folder**
2. **Share on forums/Discord**
3. **Include installation instructions**
### Plugin Store (Future)
EU-Utility may include a plugin store for easy installation.
---
**Happy plugin development!** 🚀