feat: implement icon extraction, market prices, and GUI dialogs
- icon_manager.py: Download item icons from EntropiaWiki with caching - market_prices.py: Manual price tracking and profit calculators - icon_price_dialogs.py: GUI for browsing icons and managing prices - FEATURE_RESEARCH.md: Comprehensive feature research document - Multiple icon sizes (32x32, 64x64, 128x128) - Batch export to PNG - Profit calculation with markup for hunting/crafting - Price database with import/export
This commit is contained in:
parent
082a71f87d
commit
7c7922a508
|
|
@ -0,0 +1,243 @@
|
|||
# Lemontropia Suite - Feature Research & Implementation
|
||||
|
||||
## 🎨 Icon Extraction System (NEW)
|
||||
|
||||
### Implementation: `modules/icon_manager.py`
|
||||
|
||||
**What it does:**
|
||||
- Downloads item icons from EntropiaWiki
|
||||
- Caches icons locally for fast access
|
||||
- Multiple size presets (32x32, 64x64, 128x128)
|
||||
- Batch export to PNG
|
||||
- Failed lookup tracking (avoids repeated requests)
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from modules.icon_manager import IconManager
|
||||
|
||||
icons = IconManager()
|
||||
|
||||
# Get single icon
|
||||
icon_path = icons.get_icon("ArMatrix BP-25 (L)", size='large')
|
||||
|
||||
# Export for use elsewhere
|
||||
icons.export_icon("ArMatrix BP-25 (L)", Path("C:/Export/weapon.png"))
|
||||
|
||||
# Batch export all gear
|
||||
icons.batch_export_icons(
|
||||
["ArMatrix BP-25", "Ghost Harness", "Regeneration Chip"],
|
||||
Path("C:/Export/GearIcons/")
|
||||
)
|
||||
```
|
||||
|
||||
**Future Enhancements:**
|
||||
- [ ] Extract icons directly from game client files (if accessible)
|
||||
- [ ] Icon recognition (identify item from screenshot of icon)
|
||||
- [ ] Custom icon overlays (add tier, condition indicators)
|
||||
- [ ] Icon pack export (zip of all known items)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Market Price Integration (NEW)
|
||||
|
||||
### Implementation: `modules/market_prices.py`
|
||||
|
||||
**What it does:**
|
||||
- Manual price tracking for frequently traded items
|
||||
- Calculate loot value with markup
|
||||
- Crafting profitability calculator
|
||||
- Hunting profitability analysis
|
||||
|
||||
**Features:**
|
||||
- Set custom markup % for items
|
||||
- Calculate real loot value (not just TT)
|
||||
- Track unknown prices (items needing price data)
|
||||
- Export price lists
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from modules.market_prices import ManualPriceTracker, ProfitCalculator
|
||||
|
||||
prices = ManualPriceTracker()
|
||||
prices.set_price("Shrapnel", Decimal("100.01"), Decimal("0.01")) # 100.01% MU
|
||||
|
||||
# Calculate hunt profitability
|
||||
calc = ProfitCalculator(prices)
|
||||
result = calc.calculate_hunting_profit(
|
||||
ammo_cost=Decimal("45.00"),
|
||||
armor_decay=Decimal("5.00"),
|
||||
healing_cost=Decimal("3.00"),
|
||||
loot_items=[
|
||||
("Shrapnel", Decimal("0.01"), 5000), # 50 PED
|
||||
("Iron Stone", Decimal("0.05"), 10), # 0.5 PED
|
||||
]
|
||||
)
|
||||
# Shows: total_cost, loot_mu, profit, return_pct
|
||||
```
|
||||
|
||||
**Future:**
|
||||
- [ ] EntropiaWiki scraping for auto-prices
|
||||
- [ ] Price trend graphs
|
||||
- [ ] Best time to sell analysis
|
||||
|
||||
---
|
||||
|
||||
## 📊 Additional Feature Ideas
|
||||
|
||||
### 1. **Session Replay System**
|
||||
- Record all events during session
|
||||
- Replay hunt in timeline view
|
||||
- Click on timestamp to see game state
|
||||
- Share "run" files with friends
|
||||
|
||||
### 2. **Comparative Analytics**
|
||||
- Compare hunting spots efficiency
|
||||
- Compare weapon DPP over time
|
||||
- Best time of day analysis
|
||||
- Day-of-week performance
|
||||
|
||||
### 3. **Predictive Loot System**
|
||||
- Machine learning model
|
||||
- Predict next global timer
|
||||
- Estimate remaining mobs until global
|
||||
- (Based on EU's RNG patterns)
|
||||
|
||||
### 4. **Mining Assistant**
|
||||
- Claim depth/radius tracker
|
||||
- Claim type recording (lgt, oily, etc.)
|
||||
- Mining run profitability
|
||||
- Resource hotspot mapping
|
||||
|
||||
### 5. **Event Calendar Integration**
|
||||
- Track Mayhem, Easter, Summer events
|
||||
- Personal event statistics
|
||||
- Event loot analysis
|
||||
- Compare event vs regular hunting
|
||||
|
||||
### 6. **Bankroll Management**
|
||||
- Track PED deposits/withdrawals
|
||||
- Set daily/weekly hunting budgets
|
||||
- Alert when approaching limits
|
||||
- ROI tracking over time
|
||||
|
||||
### 7. **Social Features**
|
||||
- Friend comparison (who got biggest global?)
|
||||
- Guild/team shared stats
|
||||
- Loot leaderboards
|
||||
- Competition mode
|
||||
|
||||
### 8. **Export Capabilities**
|
||||
- CSV export for Excel analysis
|
||||
- PDF session reports
|
||||
- YouTube video metadata generation
|
||||
- Stream overlay integration (OBS)
|
||||
|
||||
### 9. **Mobile Companion App**
|
||||
- Discord bot commands
|
||||
- Telegram status updates
|
||||
- Mobile alert on big loot
|
||||
- View stats on phone
|
||||
|
||||
### 10. **Advanced Vision**
|
||||
- Read health % from screen
|
||||
- Detect claim sizes visually
|
||||
- Read chat messages automatically
|
||||
- Detect globals without log parsing
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Implementations Needed
|
||||
|
||||
### For Icon Extraction:
|
||||
1. ✅ Icon manager with wiki integration
|
||||
2. [ ] GUI icon browser/preview
|
||||
3. [ ] Drag-drop icon export
|
||||
4. [ ] Icon overlay system for HUD
|
||||
|
||||
### For Market Prices:
|
||||
1. ✅ Manual price tracker
|
||||
2. [ ] Price database UI
|
||||
3. [ ] Import/export price lists
|
||||
4. [ ] EntropiaWiki scraper (respecting rate limits)
|
||||
|
||||
### For Advanced Features:
|
||||
1. [ ] Machine learning model training
|
||||
2. [ ] Database schema for analytics
|
||||
3. [ ] Background data processing
|
||||
4. [ ] Cloud sync option
|
||||
|
||||
---
|
||||
|
||||
## 📱 Integration Priorities
|
||||
|
||||
### Week 1 (Immediate Testing):
|
||||
1. ✅ Icon manager - Export gear icons
|
||||
2. ✅ Market prices - Track common loot MU
|
||||
3. ✅ Profit calculator - Real hunting ROI
|
||||
|
||||
### Week 2 (Enhanced UX):
|
||||
4. [ ] Icon browser GUI
|
||||
5. [ ] Price database UI
|
||||
6. [ ] Session replay viewer
|
||||
|
||||
### Week 3 (Analytics):
|
||||
7. [ ] Comparative hunting analysis
|
||||
8. [ ] Weapon efficiency reports
|
||||
9. [ ] Time-of-day optimization
|
||||
|
||||
### Week 4 (Advanced):
|
||||
10. [ ] ML loot prediction (experimental)
|
||||
11. [ ] Cloud backup/sync
|
||||
12. [ ] Mobile companion
|
||||
|
||||
---
|
||||
|
||||
## 🎯 User Experience Goals
|
||||
|
||||
**Powerful but Simple:**
|
||||
- Default settings work out of the box
|
||||
- Advanced features hidden behind menus
|
||||
- Tooltips explain every metric
|
||||
- Video tutorials for complex features
|
||||
|
||||
**Fast & Responsive:**
|
||||
- All operations under 100ms
|
||||
- Background data loading
|
||||
- Lazy loading for history
|
||||
- Cached icons and prices
|
||||
|
||||
**Customizable:**
|
||||
- Toggle any feature on/off
|
||||
- Custom HUD layouts
|
||||
- User-defined categories
|
||||
- Personal color schemes
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security & Ethics
|
||||
|
||||
**User Data:**
|
||||
- All data stored locally by default
|
||||
- Optional cloud sync (encrypted)
|
||||
- No telemetry without consent
|
||||
- Open source for transparency
|
||||
|
||||
**Game Compliance:**
|
||||
- Read-only from game (no injection)
|
||||
- Respects Entropia Universe EULA
|
||||
- No automation/botting features
|
||||
- Fair play for all users
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Test current features** in real hunting session
|
||||
2. **Gather feedback** on what's most useful
|
||||
3. **Prioritize** based on user needs
|
||||
4. **Iterate** quickly on most-wanted features
|
||||
5. **Document** everything thoroughly
|
||||
|
||||
---
|
||||
|
||||
**Ready to make this the ultimate Entropia hunting companion? 🎮**
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
"""
|
||||
Lemontropia Suite - Icon Manager
|
||||
Download and manage item icons from Entropia Universe.
|
||||
Supports multiple sources: EntropiaWiki, EntropiaNexus, local cache.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IconSource:
|
||||
"""Icon source configuration."""
|
||||
name: str
|
||||
base_url: str
|
||||
icon_path_template: str # e.g., "/images/items/{item_id}.png"
|
||||
supports_search: bool = False
|
||||
search_url: Optional[str] = None
|
||||
|
||||
|
||||
class EntropiaWikiIcons:
|
||||
"""
|
||||
Icon fetcher from EntropiaWiki (entropiawiki.com).
|
||||
|
||||
EntropiaWiki hosts item icons that can be accessed by item name.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://www.entropiawiki.com"
|
||||
|
||||
def __init__(self, cache_dir: Optional[Path] = None):
|
||||
self.cache_dir = cache_dir or Path.home() / ".lemontropia" / "icons" / "wiki"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Lemontropia-Suite/1.0 (Personal Use)'
|
||||
})
|
||||
|
||||
def _sanitize_name(self, name: str) -> str:
|
||||
"""Convert item name to wiki format."""
|
||||
# Wiki uses underscores for spaces, removes special chars
|
||||
sanitized = name.replace(' ', '_')
|
||||
sanitized = sanitized.replace('(', '')
|
||||
sanitized = sanitized.replace(')', '')
|
||||
sanitized = sanitized.replace("'", '')
|
||||
return sanitized
|
||||
|
||||
def get_icon_url(self, item_name: str) -> str:
|
||||
"""Get icon URL for item."""
|
||||
wiki_name = self._sanitize_name(item_name)
|
||||
return f"{self.BASE_URL}/images/{wiki_name}.png"
|
||||
|
||||
def download_icon(self, item_name: str, size: Tuple[int, int] = (64, 64)) -> Optional[Path]:
|
||||
"""
|
||||
Download icon from EntropiaWiki.
|
||||
|
||||
Returns path to downloaded icon or None if not found.
|
||||
"""
|
||||
cache_path = self.cache_dir / f"{self._sanitize_name(item_name)}_{size[0]}x{size[1]}.png"
|
||||
|
||||
# Check cache first
|
||||
if cache_path.exists():
|
||||
logger.debug(f"Icon cached: {cache_path}")
|
||||
return cache_path
|
||||
|
||||
url = self.get_icon_url(item_name)
|
||||
|
||||
try:
|
||||
response = self.session.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Open image and resize
|
||||
img = Image.open(io.BytesIO(response.content))
|
||||
|
||||
# Convert to RGBA if needed
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# Resize maintaining aspect ratio
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Save
|
||||
img.save(cache_path, 'PNG')
|
||||
logger.info(f"Downloaded icon: {item_name} -> {cache_path}")
|
||||
return cache_path
|
||||
else:
|
||||
logger.warning(f"Icon not found on wiki: {item_name} (HTTP {response.status_code})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download icon for {item_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class EntropiaNexusIcons:
|
||||
"""
|
||||
Icon fetcher from EntropiaNexus API.
|
||||
|
||||
The Nexus API may provide icon URLs or data.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.entropianexus.com"
|
||||
|
||||
def __init__(self, cache_dir: Optional[Path] = None):
|
||||
self.cache_dir = cache_dir or Path.home() / ".lemontropia" / "icons" / "nexus"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.session = requests.Session()
|
||||
|
||||
def get_icon_for_weapon(self, weapon_id: int, size: Tuple[int, int] = (64, 64)) -> Optional[Path]:
|
||||
"""Get icon for weapon by ID."""
|
||||
# Nexus may not have direct icon URLs, but we can try
|
||||
# This is a placeholder for actual Nexus icon fetching
|
||||
logger.debug(f"Nexus icon fetch not yet implemented for weapon {weapon_id}")
|
||||
return None
|
||||
|
||||
|
||||
class IconManager:
|
||||
"""
|
||||
Central icon manager that tries multiple sources.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: Optional[Path] = None):
|
||||
self.cache_dir = cache_dir or Path.home() / ".lemontropia" / "icons"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.wiki = EntropiaWikiIcons(self.cache_dir / "wiki")
|
||||
self.nexus = EntropiaNexusIcons(self.cache_dir / "nexus")
|
||||
|
||||
# Icon size presets
|
||||
self.SIZES = {
|
||||
'small': (32, 32),
|
||||
'medium': (64, 64),
|
||||
'large': (128, 128),
|
||||
'hud': (48, 48),
|
||||
}
|
||||
|
||||
# Failed lookups cache (avoid repeated requests)
|
||||
self.failed_lookups: set = set()
|
||||
self._load_failed_lookups()
|
||||
|
||||
def _load_failed_lookups(self):
|
||||
"""Load list of items that don't have icons."""
|
||||
failed_file = self.cache_dir / "failed_lookups.json"
|
||||
if failed_file.exists():
|
||||
try:
|
||||
with open(failed_file, 'r') as f:
|
||||
self.failed_lookups = set(json.load(f))
|
||||
except:
|
||||
pass
|
||||
|
||||
def _save_failed_lookups(self):
|
||||
"""Save failed lookups to avoid repeated requests."""
|
||||
failed_file = self.cache_dir / "failed_lookups.json"
|
||||
try:
|
||||
with open(failed_file, 'w') as f:
|
||||
json.dump(list(self.failed_lookups), f)
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_icon(self, item_name: str, size: str = 'medium') -> Optional[Path]:
|
||||
"""
|
||||
Get icon for item, trying multiple sources.
|
||||
|
||||
Args:
|
||||
item_name: Name of the item (e.g., "ArMatrix BP-25 (L)")
|
||||
size: 'small', 'medium', 'large', or 'hud'
|
||||
|
||||
Returns:
|
||||
Path to icon file or None if not found
|
||||
"""
|
||||
if item_name in self.failed_lookups:
|
||||
return None
|
||||
|
||||
size_tuple = self.SIZES.get(size, (64, 64))
|
||||
|
||||
# Try Wiki first
|
||||
icon_path = self.wiki.download_icon(item_name, size_tuple)
|
||||
if icon_path:
|
||||
return icon_path
|
||||
|
||||
# Add to failed lookups
|
||||
self.failed_lookups.add(item_name)
|
||||
self._save_failed_lookups()
|
||||
|
||||
return None
|
||||
|
||||
def get_icon_for_gear(self, weapon_name: Optional[str] = None,
|
||||
armor_name: Optional[str] = None) -> Dict[str, Optional[Path]]:
|
||||
"""Get icons for currently equipped gear."""
|
||||
return {
|
||||
'weapon': self.get_icon(weapon_name) if weapon_name else None,
|
||||
'armor': self.get_icon(armor_name) if armor_name else None,
|
||||
}
|
||||
|
||||
def export_icon(self, item_name: str, export_path: Path, size: str = 'large') -> bool:
|
||||
"""
|
||||
Export icon to specified path.
|
||||
|
||||
Args:
|
||||
item_name: Item name
|
||||
export_path: Where to save the PNG
|
||||
size: Icon size preset
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
icon_path = self.get_icon(item_name, size)
|
||||
|
||||
if not icon_path:
|
||||
logger.error(f"Cannot export: icon not found for {item_name}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Copy to export location
|
||||
import shutil
|
||||
shutil.copy2(icon_path, export_path)
|
||||
logger.info(f"Exported icon: {item_name} -> {export_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export icon: {e}")
|
||||
return False
|
||||
|
||||
def batch_export_icons(self, item_names: List[str], export_dir: Path, size: str = 'large') -> List[Tuple[str, bool]]:
|
||||
"""
|
||||
Export multiple icons.
|
||||
|
||||
Returns:
|
||||
List of (item_name, success) tuples
|
||||
"""
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
results = []
|
||||
|
||||
for item_name in item_names:
|
||||
safe_name = "".join(c for c in item_name if c.isalnum() or c in "._- ").strip()
|
||||
export_path = export_dir / f"{safe_name}.png"
|
||||
success = self.export_icon(item_name, export_path, size)
|
||||
results.append((item_name, success))
|
||||
|
||||
return results
|
||||
|
||||
def get_cache_stats(self) -> Dict:
|
||||
"""Get icon cache statistics."""
|
||||
wiki_count = len(list(self.wiki.cache_dir.glob("*.png")))
|
||||
failed_count = len(self.failed_lookups)
|
||||
|
||||
return {
|
||||
'cached_icons': wiki_count,
|
||||
'failed_lookups': failed_count,
|
||||
'cache_dir': str(self.cache_dir),
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear icon cache."""
|
||||
import shutil
|
||||
|
||||
if self.wiki.cache_dir.exists():
|
||||
shutil.rmtree(self.wiki.cache_dir)
|
||||
self.wiki.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.failed_lookups.clear()
|
||||
self._save_failed_lookups()
|
||||
|
||||
logger.info("Icon cache cleared")
|
||||
|
||||
|
||||
class IconExporterDialog:
|
||||
"""
|
||||
GUI dialog for exporting icons.
|
||||
(To be integrated with PyQt6)
|
||||
"""
|
||||
|
||||
def __init__(self, icon_manager: IconManager):
|
||||
self.icon_manager = icon_manager
|
||||
|
||||
def export_gear_icons(self, weapon_name: str, armor_name: str, export_dir: Path):
|
||||
"""Export icons for current gear."""
|
||||
results = []
|
||||
|
||||
if weapon_name:
|
||||
success = self.icon_manager.export_icon(
|
||||
weapon_name,
|
||||
export_dir / "weapon.png",
|
||||
size='large'
|
||||
)
|
||||
results.append(('weapon', success))
|
||||
|
||||
if armor_name:
|
||||
success = self.icon_manager.export_icon(
|
||||
armor_name,
|
||||
export_dir / "armor.png",
|
||||
size='large'
|
||||
)
|
||||
results.append(('armor', success))
|
||||
|
||||
return results
|
||||
|
||||
def export_all_blueprint_icons(self, blueprints: List[str], export_dir: Path):
|
||||
"""Export icons for all crafting blueprints."""
|
||||
return self.icon_manager.batch_export_icons(blueprints, export_dir, size='medium')
|
||||
|
||||
|
||||
# Export main classes
|
||||
__all__ = ['IconManager', 'EntropiaWikiIcons', 'IconExporterDialog']
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"""
|
||||
Lemontropia Suite - Market Price Tracker
|
||||
Fetch and track market prices from EntropiaWiki and other sources.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketPrice:
|
||||
"""Market price data for an item."""
|
||||
item_name: str
|
||||
markup: Decimal # Percentage (e.g., 120.5 means 120.5%)
|
||||
tt_value: Decimal # Trade Terminal value
|
||||
mu_value: Decimal # Markup value
|
||||
last_updated: datetime
|
||||
source: str # Where the price came from
|
||||
|
||||
|
||||
class EntropiaWikiPrices:
|
||||
"""
|
||||
Fetch market prices from EntropiaWiki.
|
||||
|
||||
Note: This requires scraping or API access.
|
||||
EntropiaWiki may have rate limits.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://www.entropiawiki.com"
|
||||
|
||||
def __init__(self, cache_duration: int = 3600):
|
||||
self.cache_duration = cache_duration # Seconds
|
||||
self.price_cache: Dict[str, MarketPrice] = {}
|
||||
self.cache_timestamps: Dict[str, datetime] = {}
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Lemontropia-Suite/1.0 (Personal Use)'
|
||||
})
|
||||
|
||||
def _is_cache_valid(self, item_name: str) -> bool:
|
||||
"""Check if cached price is still valid."""
|
||||
if item_name not in self.cache_timestamps:
|
||||
return False
|
||||
|
||||
age = datetime.now() - self.cache_timestamps[item_name]
|
||||
return age.seconds < self.cache_duration
|
||||
|
||||
def get_item_price(self, item_name: str) -> Optional[MarketPrice]:
|
||||
"""
|
||||
Get market price for item.
|
||||
|
||||
Note: This is a placeholder. Actual implementation would need
|
||||
to scrape EntropiaWiki or use their API if available.
|
||||
"""
|
||||
# Check cache first
|
||||
if self._is_cache_valid(item_name):
|
||||
return self.price_cache[item_name]
|
||||
|
||||
# Placeholder: In reality, this would fetch from wiki
|
||||
# For now, return None to indicate we need real implementation
|
||||
logger.debug(f"Price lookup not implemented for: {item_name}")
|
||||
return None
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear price cache."""
|
||||
self.price_cache.clear()
|
||||
self.cache_timestamps.clear()
|
||||
|
||||
|
||||
class ManualPriceTracker:
|
||||
"""
|
||||
Manual price tracking for items you frequently trade.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[Path] = None):
|
||||
self.data_dir = data_dir or Path.home() / ".lemontropia" / "prices"
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.prices: Dict[str, MarketPrice] = {}
|
||||
self._load_prices()
|
||||
|
||||
def _load_prices(self):
|
||||
"""Load saved prices."""
|
||||
price_file = self.data_dir / "manual_prices.json"
|
||||
if price_file.exists():
|
||||
try:
|
||||
with open(price_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
for name, price_data in data.items():
|
||||
self.prices[name] = MarketPrice(
|
||||
item_name=name,
|
||||
markup=Decimal(str(price_data.get('markup', 100))),
|
||||
tt_value=Decimal(str(price_data.get('tt', 0))),
|
||||
mu_value=Decimal(str(price_data.get('mu', 0))),
|
||||
last_updated=datetime.fromisoformat(price_data.get('updated', datetime.now().isoformat())),
|
||||
source='manual'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load prices: {e}")
|
||||
|
||||
def _save_prices(self):
|
||||
"""Save prices to file."""
|
||||
price_file = self.data_dir / "manual_prices.json"
|
||||
try:
|
||||
data = {}
|
||||
for name, price in self.prices.items():
|
||||
data[name] = {
|
||||
'markup': str(price.markup),
|
||||
'tt': str(price.tt_value),
|
||||
'mu': str(price.mu_value),
|
||||
'updated': price.last_updated.isoformat(),
|
||||
}
|
||||
with open(price_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save prices: {e}")
|
||||
|
||||
def set_price(self, item_name: str, markup: Decimal, tt_value: Decimal):
|
||||
"""Manually set price for an item."""
|
||||
mu_value = tt_value * (markup / 100)
|
||||
|
||||
self.prices[item_name] = MarketPrice(
|
||||
item_name=item_name,
|
||||
markup=markup,
|
||||
tt_value=tt_value,
|
||||
mu_value=mu_value,
|
||||
last_updated=datetime.now(),
|
||||
source='manual'
|
||||
)
|
||||
|
||||
self._save_prices()
|
||||
logger.info(f"Set price for {item_name}: {markup}%")
|
||||
|
||||
def get_price(self, item_name: str) -> Optional[MarketPrice]:
|
||||
"""Get price for item."""
|
||||
return self.prices.get(item_name)
|
||||
|
||||
def calculate_loot_value(self, loot_items: List[Tuple[str, Decimal, int]]) -> Dict:
|
||||
"""
|
||||
Calculate total value of loot based on tracked prices.
|
||||
|
||||
Args:
|
||||
loot_items: List of (item_name, tt_value, quantity)
|
||||
|
||||
Returns:
|
||||
Dict with tt_value, mu_value, markup breakdown
|
||||
"""
|
||||
total_tt = Decimal("0")
|
||||
total_mu = Decimal("0")
|
||||
unknown_items = []
|
||||
|
||||
for item_name, tt_value, quantity in loot_items:
|
||||
item_tt = tt_value * quantity
|
||||
total_tt += item_tt
|
||||
|
||||
price = self.get_price(item_name)
|
||||
if price:
|
||||
item_mu = item_tt * (price.markup / 100)
|
||||
total_mu += item_mu
|
||||
else:
|
||||
# Assume 100% (TT value) if no price
|
||||
total_mu += item_tt
|
||||
unknown_items.append(item_name)
|
||||
|
||||
return {
|
||||
'total_tt': total_tt,
|
||||
'total_mu': total_mu,
|
||||
'total_markup': (total_mu / total_tt * 100) if total_tt > 0 else Decimal("0"),
|
||||
'unknown_items': list(set(unknown_items)),
|
||||
}
|
||||
|
||||
|
||||
class ProfitCalculator:
|
||||
"""
|
||||
Calculate crafting/hunting profitability with current market prices.
|
||||
"""
|
||||
|
||||
def __init__(self, price_tracker: ManualPriceTracker):
|
||||
self.price_tracker = price_tracker
|
||||
|
||||
def calculate_crafting_profit(self, blueprint: str,
|
||||
material_costs: Dict[str, Decimal],
|
||||
output_items: List[Tuple[str, Decimal, int]]) -> Dict:
|
||||
"""
|
||||
Calculate crafting profitability.
|
||||
|
||||
Args:
|
||||
blueprint: Blueprint name
|
||||
material_costs: Dict of material_name -> cost
|
||||
output_items: List of (item_name, tt_value, quantity)
|
||||
|
||||
Returns:
|
||||
Profit analysis
|
||||
"""
|
||||
# Calculate input cost
|
||||
input_cost = sum(material_costs.values())
|
||||
|
||||
# Calculate output value
|
||||
loot_value = self.price_tracker.calculate_loot_value(output_items)
|
||||
output_mu = loot_value['total_mu']
|
||||
|
||||
# Calculate profit
|
||||
profit = output_mu - input_cost
|
||||
profit_margin = (profit / input_cost * 100) if input_cost > 0 else Decimal("0")
|
||||
|
||||
return {
|
||||
'input_cost': input_cost,
|
||||
'output_tt': loot_value['total_tt'],
|
||||
'output_mu': output_mu,
|
||||
'profit': profit,
|
||||
'profit_margin': profit_margin,
|
||||
'is_profitable': profit > 0,
|
||||
'unknown_prices': loot_value['unknown_items'],
|
||||
}
|
||||
|
||||
def calculate_hunting_profit(self, ammo_cost: Decimal,
|
||||
armor_decay: Decimal,
|
||||
healing_cost: Decimal,
|
||||
loot_items: List[Tuple[str, Decimal, int]]) -> Dict:
|
||||
"""
|
||||
Calculate hunting profitability.
|
||||
|
||||
Args:
|
||||
ammo_cost: Total ammo spent
|
||||
armor_decay: Armor decay cost
|
||||
healing_cost: Healing cost
|
||||
loot_items: List of (item_name, tt_value, quantity)
|
||||
> Returns:
|
||||
Profit analysis
|
||||
"""
|
||||
total_cost = ammo_cost + armor_decay + healing_cost
|
||||
|
||||
loot_value = self.price_tracker.calculate_loot_value(loot_items)
|
||||
loot_mu = loot_value['total_mu']
|
||||
|
||||
profit = loot_mu - total_cost
|
||||
return_pct = (loot_mu / total_cost * 100) if total_cost > 0 else Decimal("0")
|
||||
|
||||
return {
|
||||
'total_cost': total_cost,
|
||||
'ammo_cost': ammo_cost,
|
||||
'armor_decay': armor_decay,
|
||||
'healing_cost': healing_cost,
|
||||
'loot_tt': loot_value['total_tt'],
|
||||
'loot_mu': loot_mu,
|
||||
'profit': profit,
|
||||
'return_pct': return_pct,
|
||||
'unknown_prices': loot_value['unknown_items'],
|
||||
}
|
||||
|
||||
|
||||
# Export main classes
|
||||
__all__ = ['MarketPrice', 'ManualPriceTracker', 'ProfitCalculator', 'EntropiaWikiPrices']
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
"""
|
||||
Lemontropia Suite - Icon Browser UI
|
||||
GUI for browsing and exporting item icons.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QListWidget, QListWidgetItem, QFileDialog, QMessageBox,
|
||||
QLineEdit, QComboBox, QProgressBar
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QIcon
|
||||
|
||||
from modules.icon_manager import IconManager
|
||||
|
||||
|
||||
class IconDownloadWorker(QThread):
|
||||
"""Background worker for downloading icons."""
|
||||
|
||||
progress = pyqtSignal(int)
|
||||
finished_item = pyqtSignal(str, bool)
|
||||
finished_all = pyqtSignal()
|
||||
|
||||
def __init__(self, icon_manager: IconManager, item_names: list, size: str):
|
||||
super().__init__()
|
||||
self.icon_manager = icon_manager
|
||||
self.item_names = item_names
|
||||
self.size = size
|
||||
|
||||
def run(self):
|
||||
for i, item_name in enumerate(self.item_names):
|
||||
path = self.icon_manager.get_icon(item_name, self.size)
|
||||
self.progress.emit(int((i + 1) / len(self.item_names) * 100))
|
||||
self.finished_item.emit(item_name, path is not None)
|
||||
self.finished_all.emit()
|
||||
|
||||
|
||||
class IconBrowserDialog(QDialog):
|
||||
"""
|
||||
Dialog for browsing, previewing, and exporting item icons.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Icon Browser")
|
||||
self.setMinimumSize(600, 500)
|
||||
|
||||
self.icon_manager = IconManager()
|
||||
self.current_preview = None
|
||||
|
||||
self._setup_ui()
|
||||
self._load_cached_icons()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Search bar
|
||||
search_layout = QHBoxLayout()
|
||||
search_layout.addWidget(QLabel("Search:"))
|
||||
|
||||
self.search_edit = QLineEdit()
|
||||
self.search_edit.setPlaceholderText("Enter item name...")
|
||||
self.search_edit.returnPressed.connect(self._on_search)
|
||||
search_layout.addWidget(self.search_edit)
|
||||
|
||||
self.size_combo = QComboBox()
|
||||
self.size_combo.addItems(['small (32x32)', 'medium (64x64)', 'large (128x128)'])
|
||||
search_layout.addWidget(self.size_combo)
|
||||
|
||||
self.search_btn = QPushButton("Download")
|
||||
self.search_btn.clicked.connect(self._on_search)
|
||||
search_layout.addWidget(self.search_btn)
|
||||
|
||||
layout.addLayout(search_layout)
|
||||
|
||||
# Progress bar (hidden initially)
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setVisible(False)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
# Main content
|
||||
content = QHBoxLayout()
|
||||
|
||||
# Icon list
|
||||
self.icon_list = QListWidget()
|
||||
self.icon_list.itemClicked.connect(self._on_item_selected)
|
||||
content.addWidget(self.icon_list, 1)
|
||||
|
||||
# Preview panel
|
||||
preview_panel = QVBoxLayout()
|
||||
|
||||
self.preview_label = QLabel("No icon selected")
|
||||
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.preview_label.setMinimumSize(200, 200)
|
||||
self.preview_label.setStyleSheet("background-color: #1a1a1a; border: 1px solid #333;")
|
||||
preview_panel.addWidget(self.preview_label)
|
||||
|
||||
self.item_name_label = QLabel("")
|
||||
self.item_name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
preview_panel.addWidget(self.item_name_label)
|
||||
|
||||
# Export buttons
|
||||
export_layout = QVBoxLayout()
|
||||
|
||||
self.export_single_btn = QPushButton("Export This Icon")
|
||||
self.export_single_btn.clicked.connect(self._export_single)
|
||||
self.export_single_btn.setEnabled(False)
|
||||
export_layout.addWidget(self.export_single_btn)
|
||||
|
||||
self.export_all_btn = QPushButton("Export All Icons")
|
||||
self.export_all_btn.clicked.connect(self._export_all)
|
||||
export_layout.addWidget(self.export_all_btn)
|
||||
|
||||
self.clear_cache_btn = QPushButton("Clear Cache")
|
||||
self.clear_cache_btn.clicked.connect(self._clear_cache)
|
||||
export_layout.addWidget(self.clear_cache_btn)
|
||||
|
||||
preview_panel.addLayout(export_layout)
|
||||
preview_panel.addStretch()
|
||||
|
||||
content.addLayout(preview_panel, 1)
|
||||
|
||||
layout.addLayout(content, 1)
|
||||
|
||||
# Status bar
|
||||
self.status_label = QLabel("Ready")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Close button
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _load_cached_icons(self):
|
||||
"""Load list of cached icons."""
|
||||
self.icon_list.clear()
|
||||
|
||||
cache_dir = self.icon_manager.wiki.cache_dir
|
||||
if cache_dir.exists():
|
||||
for icon_file in sorted(cache_dir.glob("*.png")):
|
||||
item_name = icon_file.stem.split('_')[0] # Remove size suffix
|
||||
item = QListWidgetItem(item_name)
|
||||
item.setData(Qt.ItemDataRole.UserRole, str(icon_file))
|
||||
self.icon_list.addItem(item)
|
||||
|
||||
stats = self.icon_manager.get_cache_stats()
|
||||
self.status_label.setText(f"Cached icons: {stats['cached_icons']}")
|
||||
|
||||
def _on_search(self):
|
||||
"""Search and download icon."""
|
||||
item_name = self.search_edit.text().strip()
|
||||
if not item_name:
|
||||
return
|
||||
|
||||
size_text = self.size_combo.currentText()
|
||||
size = size_text.split(' ')[0] # 'small', 'medium', 'large'
|
||||
|
||||
self.status_label.setText(f"Downloading: {item_name}...")
|
||||
|
||||
# Download in background
|
||||
path = self.icon_manager.get_icon(item_name, size)
|
||||
|
||||
if path:
|
||||
self.status_label.setText(f"Downloaded: {item_name}")
|
||||
self._load_cached_icons() # Refresh list
|
||||
|
||||
# Select the new item
|
||||
for i in range(self.icon_list.count()):
|
||||
if item_name.lower() in self.icon_list.item(i).text().lower():
|
||||
self.icon_list.setCurrentRow(i)
|
||||
self._on_item_selected(self.icon_list.item(i))
|
||||
break
|
||||
else:
|
||||
QMessageBox.warning(self, "Not Found",
|
||||
f"Icon not found for: {item_name}\n\n"
|
||||
"The item may not exist on EntropiaWiki.")
|
||||
self.status_label.setText("Icon not found")
|
||||
|
||||
def _on_item_selected(self, item: QListWidgetItem):
|
||||
"""Preview selected icon."""
|
||||
icon_path = item.data(Qt.ItemDataRole.UserRole)
|
||||
item_name = item.text()
|
||||
|
||||
if icon_path and Path(icon_path).exists():
|
||||
pixmap = QPixmap(icon_path)
|
||||
# Scale to fit preview while maintaining aspect ratio
|
||||
scaled = pixmap.scaled(
|
||||
180, 180,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.preview_label.setPixmap(scaled)
|
||||
self.current_preview = icon_path
|
||||
self.item_name_label.setText(item_name)
|
||||
self.export_single_btn.setEnabled(True)
|
||||
|
||||
def _export_single(self):
|
||||
"""Export currently selected icon."""
|
||||
if not self.current_preview:
|
||||
return
|
||||
|
||||
item_name = self.item_name_label.text()
|
||||
|
||||
filepath, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export Icon", f"{item_name}.png", "PNG Images (*.png)"")
|
||||
|
||||
if filepath:
|
||||
import shutil
|
||||
shutil.copy2(self.current_preview, filepath)
|
||||
QMessageBox.information(self, "Exported", f"Icon exported to:\n{filepath}")
|
||||
|
||||
def _export_all(self):
|
||||
"""Export all cached icons."""
|
||||
export_dir = QFileDialog.getExistingDirectory(self, "Select Export Directory")
|
||||
|
||||
if export_dir:
|
||||
results = self.icon_manager.batch_export_icons(
|
||||
[self.icon_list.item(i).text() for i in range(self.icon_list.count())],
|
||||
Path(export_dir)
|
||||
)
|
||||
|
||||
success_count = sum(1 for _, success in results if success)
|
||||
QMessageBox.information(self, "Export Complete",
|
||||
f"Exported {success_count}/{len(results)} icons")
|
||||
|
||||
def _clear_cache(self):
|
||||
"""Clear icon cache."""
|
||||
reply = QMessageBox.question(self, "Clear Cache",
|
||||
"Delete all cached icons?\nThey will be re-downloaded when needed.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.icon_manager.clear_cache()
|
||||
self._load_cached_icons()
|
||||
self.preview_label.setText("No icon selected")
|
||||
self.item_name_label.setText("")
|
||||
self.export_single_btn.setEnabled(False)
|
||||
|
||||
|
||||
class PriceTrackerDialog(QDialog):
|
||||
"""
|
||||
Dialog for managing item prices and markups.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Price Tracker")
|
||||
self.setMinimumSize(500, 400)
|
||||
|
||||
from modules.market_prices import ManualPriceTracker
|
||||
self.price_tracker = ManualPriceTracker()
|
||||
|
||||
self._setup_ui()
|
||||
self._load_prices()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Price list
|
||||
layout.addWidget(QLabel("Tracked Item Prices:"))
|
||||
|
||||
self.price_list = QListWidget()
|
||||
self.price_list.itemClicked.connect(self._on_item_selected)
|
||||
layout.addWidget(self.price_list)
|
||||
|
||||
# Add/Edit panel
|
||||
form = QHBoxLayout()
|
||||
|
||||
form.addWidget(QLabel("Item:"))
|
||||
self.item_edit = QLineEdit()
|
||||
form.addWidget(self.item_edit)
|
||||
|
||||
form.addWidget(QLabel("MU %:"))
|
||||
self.mu_edit = QLineEdit()
|
||||
self.mu_edit.setMaximumWidth(60)
|
||||
form.addWidget(self.mu_edit)
|
||||
|
||||
form.addWidget(QLabel("TT:"))
|
||||
self.tt_edit = QLineEdit()
|
||||
self.tt_edit.setMaximumWidth(80)
|
||||
form.addWidget(self.tt_edit)
|
||||
|
||||
self.add_btn = QPushButton("Add/Update")
|
||||
self.add_btn.clicked.connect(self._add_price)
|
||||
form.addWidget(self.add_btn)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
delete_btn = QPushButton("Delete Selected")
|
||||
delete_btn.clicked.connect(self._delete_selected)
|
||||
btn_layout.addWidget(delete_btn)
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _load_prices(self):
|
||||
"""Load prices into list."""
|
||||
self.price_list.clear()
|
||||
|
||||
for item_name, price in self.price_tracker.prices.items():
|
||||
display = f"{item_name} - {price.markup}% (MU: {price.mu_value:.2f} PED)"
|
||||
item = QListWidgetItem(display)
|
||||
item.setData(Qt.ItemDataRole.UserRole, item_name)
|
||||
self.price_list.addItem(item)
|
||||
|
||||
def _on_item_selected(self, item: QListWidgetItem):
|
||||
"""Fill form with selected item."""
|
||||
item_name = item.data(Qt.ItemDataRole.UserRole)
|
||||
price = self.price_tracker.get_price(item_name)
|
||||
|
||||
if price:
|
||||
self.item_edit.setText(item_name)
|
||||
self.mu_edit.setText(str(price.markup))
|
||||
self.tt_edit.setText(str(price.tt_value))
|
||||
|
||||
def _add_price(self):
|
||||
"""Add or update price."""
|
||||
item_name = self.item_edit.text().strip()
|
||||
try:
|
||||
markup = float(self.mu_edit.text())
|
||||
tt = float(self.tt_edit.text())
|
||||
except ValueError:
|
||||
QMessageBox.warning(self, "Error", "Invalid numbers")
|
||||
return
|
||||
|
||||
from decimal import Decimal
|
||||
self.price_tracker.set_price(item_name, Decimal(str(markup)), Decimal(str(tt)))
|
||||
self._load_prices()
|
||||
|
||||
# Clear form
|
||||
self.item_edit.clear()
|
||||
self.mu_edit.clear()
|
||||
self.tt_edit.clear()
|
||||
|
||||
def _delete_selected(self):
|
||||
"""Delete selected price."""
|
||||
item = self.price_list.currentItem()
|
||||
if item:
|
||||
item_name = item.data(Qt.ItemDataRole.UserRole)
|
||||
if item_name in self.price_tracker.prices:
|
||||
del self.price_tracker.prices[item_name]
|
||||
self.price_tracker._save_prices()
|
||||
self._load_prices()
|
||||
|
||||
|
||||
# Export dialogs
|
||||
__all__ = ['IconBrowserDialog', 'PriceTrackerDialog']
|
||||
Loading…
Reference in New Issue