feat(phase2): Complete Phase 2 Integration - Bug fixes: SimpleCache self._timestamps typo, MOCK_TOOLS key typo. Agent-swarm UI: main_window, hud_overlay, loadout_manager. Nexus API with mock data. All tests passing.
This commit is contained in:
parent
a963778145
commit
97b9403b11
|
|
@ -0,0 +1,840 @@
|
|||
"""
|
||||
Entropia Nexus API Client
|
||||
|
||||
Provides async access to weapon, armor, and tool statistics from entropianexus.com.
|
||||
Includes mock data for offline testing and development.
|
||||
|
||||
API Documentation: https://api.entropianexus.com/docs/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional, Any
|
||||
from functools import wraps
|
||||
import time
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
HAS_AIOHTTP = True
|
||||
except ImportError:
|
||||
HAS_AIOHTTP = False
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
HAS_URLLIB = True
|
||||
except ImportError:
|
||||
HAS_URLLIB = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Classes
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class WeaponStats:
|
||||
"""Weapon statistics from Entropia Nexus."""
|
||||
name: str
|
||||
damage: Decimal
|
||||
decay_pec: Decimal
|
||||
ammo_pec: Decimal
|
||||
dpp: Decimal # Damage Per PEC (calculated)
|
||||
range: int = 0
|
||||
attacks_per_min: int = 0
|
||||
total_cost_pec: Decimal = field(default=None)
|
||||
markup_percent: Decimal = Decimal("100.0")
|
||||
item_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if self.total_cost_pec is None:
|
||||
self.total_cost_pec = self.decay_pec + self.ammo_pec
|
||||
# Ensure dpp is calculated if not provided
|
||||
if self.dpp == Decimal("0") and self.total_cost_pec > 0:
|
||||
self.dpp = self.damage / (self.total_cost_pec / 100) # Convert PEC to PED
|
||||
|
||||
def calculate_cost_per_hour(self) -> Decimal:
|
||||
"""Calculate total cost per hour of use in PED."""
|
||||
if self.attacks_per_min <= 0:
|
||||
return Decimal("0")
|
||||
shots_per_hour = self.attacks_per_min * 60
|
||||
return (self.total_cost_pec * shots_per_hour) / 100 # Convert PEC to PED
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'name': self.name,
|
||||
'damage': str(self.damage),
|
||||
'decay_pec': str(self.decay_pec),
|
||||
'ammo_pec': str(self.ammo_pec),
|
||||
'dpp': str(self.dpp),
|
||||
'range': self.range,
|
||||
'attacks_per_min': self.attacks_per_min,
|
||||
'total_cost_pec': str(self.total_cost_pec),
|
||||
'markup_percent': str(self.markup_percent),
|
||||
'item_id': self.item_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'WeaponStats':
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
name=data['name'],
|
||||
damage=Decimal(data.get('damage', 0)),
|
||||
decay_pec=Decimal(data.get('decay_pec', 0)),
|
||||
ammo_pec=Decimal(data.get('ammo_pec', 0)),
|
||||
dpp=Decimal(data.get('dpp', 0)),
|
||||
range=data.get('range', 0),
|
||||
attacks_per_min=data.get('attacks_per_min', 0),
|
||||
total_cost_pec=Decimal(data.get('total_cost_pec', data.get('decay_pec', 0))) if 'total_cost_pec' in data else None,
|
||||
markup_percent=Decimal(data.get('markup_percent', 100)),
|
||||
item_id=data.get('item_id', ''),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArmorStats:
|
||||
"""Armor piece statistics from Entropia Nexus."""
|
||||
name: str
|
||||
decay_pec: Decimal
|
||||
protection: Dict[str, Decimal]
|
||||
slot: str = "body" # head, body, arms, legs, feet
|
||||
durability: int = 10000
|
||||
item_id: str = ""
|
||||
|
||||
def get_total_protection(self) -> Decimal:
|
||||
"""Get sum of all protection values."""
|
||||
return sum(self.protection.values(), Decimal("0"))
|
||||
|
||||
def get_protection(self, damage_type: str) -> Decimal:
|
||||
"""Get protection value for specific damage type."""
|
||||
return self.protection.get(damage_type, Decimal("0"))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'name': self.name,
|
||||
'decay_pec': str(self.decay_pec),
|
||||
'protection': {k: str(v) for k, v in self.protection.items()},
|
||||
'slot': self.slot,
|
||||
'durability': self.durability,
|
||||
'item_id': self.item_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ArmorStats':
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
name=data['name'],
|
||||
decay_pec=Decimal(data.get('decay_pec', 0)),
|
||||
protection={k: Decimal(v) for k, v in data.get('protection', {}).items()},
|
||||
slot=data.get('slot', 'body'),
|
||||
durability=data.get('durability', 10000),
|
||||
item_id=data.get('item_id', ''),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStats:
|
||||
"""Mining tool statistics from Entropia Nexus."""
|
||||
name: str
|
||||
depth: Decimal
|
||||
radius: Decimal
|
||||
decay_pec: Decimal
|
||||
tool_type: str = "finder" # finder or extractor
|
||||
probe_cost: Decimal = Decimal("0.5") # PED per drop
|
||||
item_id: str = ""
|
||||
|
||||
def calculate_cost_per_drop(self) -> Decimal:
|
||||
"""Calculate total cost per mining drop in PED."""
|
||||
return (self.decay_pec / 100) + self.probe_cost # PEC to PED + probe
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'name': self.name,
|
||||
'depth': str(self.depth),
|
||||
'radius': str(self.radius),
|
||||
'decay_pec': str(self.decay_pec),
|
||||
'tool_type': self.tool_type,
|
||||
'probe_cost': str(self.probe_cost),
|
||||
'item_id': self.item_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ToolStats':
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
name=data['name'],
|
||||
depth=Decimal(data.get('depth', 0)),
|
||||
radius=Decimal(data.get('radius', 0)),
|
||||
decay_pec=Decimal(data.get('decay_pec', 0)),
|
||||
tool_type=data.get('tool_type', 'finder'),
|
||||
probe_cost=Decimal(data.get('probe_cost', 0.5)),
|
||||
item_id=data.get('item_id', ''),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mock Data
|
||||
# =============================================================================
|
||||
|
||||
MOCK_WEAPONS = {
|
||||
"sollomate_opalo": WeaponStats(
|
||||
name="Sollomate Opalo",
|
||||
damage=Decimal("4.0"),
|
||||
decay_pec=Decimal("0.13"),
|
||||
ammo_pec=Decimal("1.07"),
|
||||
dpp=Decimal("3.70"),
|
||||
range=26,
|
||||
attacks_per_min=56,
|
||||
item_id="sollomate_opalo"
|
||||
),
|
||||
"omegaton_m2100": WeaponStats(
|
||||
name="Omegaton M2100",
|
||||
damage=Decimal("5.0"),
|
||||
decay_pec=Decimal("0.15"),
|
||||
ammo_pec=Decimal("1.35"),
|
||||
dpp=Decimal("3.33"),
|
||||
range=28,
|
||||
attacks_per_min=54,
|
||||
item_id="omegaton_m2100"
|
||||
),
|
||||
"breer_m1a": WeaponStats(
|
||||
name="Breer M1a",
|
||||
damage=Decimal("6.0"),
|
||||
decay_pec=Decimal("0.18"),
|
||||
ammo_pec=Decimal("1.62"),
|
||||
dpp=Decimal("3.33"),
|
||||
range=30,
|
||||
attacks_per_min=52,
|
||||
item_id="breer_m1a"
|
||||
),
|
||||
"castorian_enforcer_se": WeaponStats(
|
||||
name="Castorian Enforcer SE",
|
||||
damage=Decimal("14.0"),
|
||||
decay_pec=Decimal("1.42"),
|
||||
ammo_pec=Decimal("6.08"),
|
||||
dpp=Decimal("1.87"),
|
||||
range=26,
|
||||
attacks_per_min=44,
|
||||
item_id="castorian_enforcer_se"
|
||||
),
|
||||
"isis_lr1": WeaponStats(
|
||||
name="ISIS LR1",
|
||||
damage=Decimal("8.0"),
|
||||
decay_pec=Decimal("0.32"),
|
||||
ammo_pec=Decimal("2.88"),
|
||||
dpp=Decimal("2.50"),
|
||||
range=60,
|
||||
attacks_per_min=38,
|
||||
item_id="isis_lr1"
|
||||
),
|
||||
"sollomate_ony": WeaponStats(
|
||||
name="Sollomate Ony",
|
||||
damage=Decimal("12.0"),
|
||||
decay_pec=Decimal("0.89"),
|
||||
ammo_pec=Decimal("5.11"),
|
||||
dpp=Decimal("2.00"),
|
||||
range=24,
|
||||
attacks_per_min=48,
|
||||
item_id="sollomate_ony"
|
||||
),
|
||||
}
|
||||
|
||||
MOCK_ARMORS = {
|
||||
"pixie": ArmorStats(
|
||||
name="Pixie",
|
||||
decay_pec=Decimal("0.135"),
|
||||
protection={
|
||||
"impact": Decimal("3.0"),
|
||||
"cut": Decimal("3.0"),
|
||||
"stab": Decimal("3.0"),
|
||||
"burn": Decimal("2.0"),
|
||||
"cold": Decimal("1.0"),
|
||||
},
|
||||
slot="body",
|
||||
durability=2800,
|
||||
item_id="pixie"
|
||||
),
|
||||
"shogun": ArmorStats(
|
||||
name="Shogun",
|
||||
decay_pec=Decimal("0.60"),
|
||||
protection={
|
||||
"impact": Decimal("10.0"),
|
||||
"cut": Decimal("10.0"),
|
||||
"stab": Decimal("10.0"),
|
||||
"burn": Decimal("6.0"),
|
||||
"cold": Decimal("6.0"),
|
||||
"acid": Decimal("4.0"),
|
||||
},
|
||||
slot="body",
|
||||
durability=4200,
|
||||
item_id="shogun"
|
||||
),
|
||||
"ghost": ArmorStats(
|
||||
name="Ghost",
|
||||
decay_pec=Decimal("0.62"),
|
||||
protection={
|
||||
"impact": Decimal("12.0"),
|
||||
"cut": Decimal("12.0"),
|
||||
"stab": Decimal("12.0"),
|
||||
"burn": Decimal("8.0"),
|
||||
"cold": Decimal("8.0"),
|
||||
"acid": Decimal("6.0"),
|
||||
},
|
||||
slot="body",
|
||||
durability=4800,
|
||||
item_id="ghost"
|
||||
),
|
||||
"vigilante": ArmorStats(
|
||||
name="Vigilante",
|
||||
decay_pec=Decimal("0.45"),
|
||||
protection={
|
||||
"impact": Decimal("8.0"),
|
||||
"cut": Decimal("8.0"),
|
||||
"stab": Decimal("8.0"),
|
||||
"burn": Decimal("5.0"),
|
||||
"cold": Decimal("5.0"),
|
||||
},
|
||||
slot="body",
|
||||
durability=3600,
|
||||
item_id="vigilante"
|
||||
),
|
||||
"necconu": ArmorStats(
|
||||
name="Necconu",
|
||||
decay_pec=Decimal("0.38"),
|
||||
protection={
|
||||
"impact": Decimal("7.0"),
|
||||
"cut": Decimal("7.0"),
|
||||
"stab": Decimal("7.0"),
|
||||
"burn": Decimal("4.0"),
|
||||
"cold": Decimal("4.0"),
|
||||
"electric": Decimal("3.0"),
|
||||
},
|
||||
slot="body",
|
||||
durability=3200,
|
||||
item_id="necconu"
|
||||
),
|
||||
}
|
||||
|
||||
MOCK_TOOLS = {
|
||||
"ziplex_z1": ToolStats(
|
||||
name="Ziplex Z1 Seeker",
|
||||
depth=Decimal("219.5"),
|
||||
radius=Decimal("22.0"),
|
||||
decay_pec=Decimal("0.20"),
|
||||
tool_type="finder",
|
||||
probe_cost=Decimal("0.5"),
|
||||
item_id="ziplex_z1"
|
||||
),
|
||||
"vrtx_1000": ToolStats(
|
||||
name="VRTX 1000",
|
||||
depth=Decimal("250.0"),
|
||||
radius=Decimal("25.0"),
|
||||
decay_pec=Decimal("0.35"),
|
||||
tool_type="finder",
|
||||
probe_cost=Decimal("0.5"),
|
||||
item_id="vrtx_1000"
|
||||
),
|
||||
"ziplex_p15": ToolStats(
|
||||
name="Ziplex P15",
|
||||
depth=Decimal("310.0"),
|
||||
radius=Decimal("30.0"),
|
||||
decay_pec=Decimal("0.50"),
|
||||
tool_type="finder",
|
||||
probe_cost=Decimal("0.5"),
|
||||
item_id="ziplex_p15"
|
||||
),
|
||||
"ore_extractor_md1": ToolStats(
|
||||
name="Ore Extractor MD-1",
|
||||
depth=Decimal("0"),
|
||||
radius=Decimal("0"),
|
||||
decay_pec=Decimal("0.15"),
|
||||
tool_type="extractor",
|
||||
item_id="ore_extractor_md1"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cache Implementation
|
||||
# =============================================================================
|
||||
|
||||
class SimpleCache:
|
||||
"""Simple in-memory cache with TTL support."""
|
||||
|
||||
def __init__(self, default_ttl: int = 3600):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._timestamps: Dict[str, float] = {}
|
||||
self._default_ttl = default_ttl
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get cached value if not expired."""
|
||||
if key not in self._cache:
|
||||
return None
|
||||
|
||||
timestamp = self._timestamps.get(key, 0)
|
||||
if time.time() - timestamp > self._default_ttl:
|
||||
self.delete(key)
|
||||
return None
|
||||
|
||||
return self._cache[key]
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
||||
"""Cache a value with optional TTL."""
|
||||
self._cache[key] = value
|
||||
self._timestamps[key] = time.time()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete a cached value."""
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached values."""
|
||||
self._cache.clear()
|
||||
self._timestamps.clear()
|
||||
|
||||
def keys(self) -> List[str]:
|
||||
"""Get all cache keys."""
|
||||
return list(self._cache.keys())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Client
|
||||
# =============================================================================
|
||||
|
||||
class EntropiaNexusAPI:
|
||||
"""
|
||||
Async client for Entropia Nexus API.
|
||||
|
||||
Provides access to weapon, armor, and tool statistics with:
|
||||
- Automatic caching of results
|
||||
- Mock data fallback for offline testing
|
||||
- Graceful error handling
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.entropianexus.com/v1"
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, use_mock_fallback: bool = True,
|
||||
cache_ttl: int = 3600, mock_mode: bool = False):
|
||||
"""
|
||||
Initialize API client.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key for higher rate limits
|
||||
use_mock_fallback: Whether to fall back to mock data on API failure
|
||||
cache_ttl: Cache time-to-live in seconds (default: 1 hour)
|
||||
mock_mode: If True, always use mock data (no API calls)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.use_mock_fallback = use_mock_fallback
|
||||
self.mock_mode = mock_mode
|
||||
self._cache = SimpleCache(default_ttl=cache_ttl)
|
||||
self._session: Optional[Any] = None
|
||||
|
||||
logger.info(f"EntropiaNexusAPI initialized (mock_mode={mock_mode})")
|
||||
|
||||
async def _get_session(self) -> Optional[Any]:
|
||||
"""Get or create aiohttp session."""
|
||||
if not HAS_AIOHTTP:
|
||||
return None
|
||||
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
headers={
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lemontropia-Suite/0.1.0'
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=10)
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def _make_request(self, endpoint: str, params: Optional[Dict] = None) -> Optional[Dict]:
|
||||
"""
|
||||
Make async API request to Entropia Nexus.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path (without base URL)
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
JSON response as dict, or None on error
|
||||
"""
|
||||
if self.mock_mode:
|
||||
return None
|
||||
|
||||
url = f"{self.BASE_URL}/{endpoint}"
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
if session is None:
|
||||
logger.warning("aiohttp not available, falling back to sync request")
|
||||
return self._make_sync_request(endpoint, params)
|
||||
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
logger.warning(f"API returned status {response.status}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
return None
|
||||
|
||||
def _make_sync_request(self, endpoint: str, params: Optional[Dict] = None) -> Optional[Dict]:
|
||||
"""Synchronous fallback request using urllib."""
|
||||
if not HAS_URLLIB or self.mock_mode:
|
||||
return None
|
||||
|
||||
url = f"{self.BASE_URL}/{endpoint}"
|
||||
|
||||
if params:
|
||||
query_string = '&'.join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items())
|
||||
url = f"{url}?{query_string}"
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lemontropia-Suite/0.1.0'
|
||||
}
|
||||
|
||||
if self.api_key:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
except Exception as e:
|
||||
logger.error(f"Sync API request failed: {e}")
|
||||
return None
|
||||
|
||||
async def search_items(self, query: str, item_type: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Search for items by name.
|
||||
|
||||
Args:
|
||||
query: Item name search term
|
||||
item_type: Optional filter ('weapon', 'armor', 'tool')
|
||||
|
||||
Returns:
|
||||
List of item data dicts with 'id', 'name', 'type' keys
|
||||
"""
|
||||
cache_key = f"search:{query}:{item_type}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Try API first
|
||||
params = {'q': query}
|
||||
if item_type:
|
||||
params['type'] = item_type
|
||||
|
||||
data = await self._make_request('items/search', params)
|
||||
|
||||
if data and 'results' in data:
|
||||
results = data['results']
|
||||
self._cache.set(cache_key, results)
|
||||
return results
|
||||
|
||||
# Fallback to mock data
|
||||
if self.use_mock_fallback:
|
||||
return self._search_mock_items(query, item_type)
|
||||
|
||||
return []
|
||||
|
||||
def _search_mock_items(self, query: str, item_type: Optional[str] = None) -> List[Dict]:
|
||||
"""Search mock items by query string."""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
|
||||
if not item_type or item_type == 'weapon':
|
||||
for item_id, weapon in MOCK_WEAPONS.items():
|
||||
if query_lower in weapon.name.lower():
|
||||
results.append({
|
||||
'id': item_id,
|
||||
'name': weapon.name,
|
||||
'type': 'weapon'
|
||||
})
|
||||
|
||||
if not item_type or item_type == 'armor':
|
||||
for item_id, armor in MOCK_ARMORS.items():
|
||||
if query_lower in armor.name.lower():
|
||||
results.append({
|
||||
'id': item_id,
|
||||
'name': armor.name,
|
||||
'type': 'armor'
|
||||
})
|
||||
|
||||
if not item_type or item_type == 'tool':
|
||||
for item_id, tool in MOCK_TOOLS.items():
|
||||
if query_lower in tool.name.lower():
|
||||
results.append({
|
||||
'id': item_id,
|
||||
'name': tool.name,
|
||||
'type': 'tool'
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
async def get_weapon_stats(self, item_id: str) -> Optional[WeaponStats]:
|
||||
"""
|
||||
Get detailed weapon statistics.
|
||||
|
||||
Args:
|
||||
item_id: Weapon item ID or name
|
||||
|
||||
Returns:
|
||||
WeaponStats object or None if not found
|
||||
"""
|
||||
cache_key = f"weapon:{item_id}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Try API first
|
||||
data = await self._make_request(f'weapons/{item_id}')
|
||||
|
||||
if data:
|
||||
try:
|
||||
weapon = WeaponStats(
|
||||
name=data['name'],
|
||||
damage=Decimal(str(data.get('damage', 0))),
|
||||
decay_pec=Decimal(str(data.get('decay_pec', 0))),
|
||||
ammo_pec=Decimal(str(data.get('ammo_pec', 0))),
|
||||
dpp=Decimal(str(data.get('dpp', 0))),
|
||||
range=data.get('range', 0),
|
||||
attacks_per_min=data.get('attacks_per_min', 0),
|
||||
markup_percent=Decimal(str(data.get('markup', 100))),
|
||||
item_id=item_id
|
||||
)
|
||||
self._cache.set(cache_key, weapon)
|
||||
return weapon
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse weapon data: {e}")
|
||||
|
||||
# Fallback to mock data
|
||||
if self.use_mock_fallback:
|
||||
normalized_id = item_id.lower().replace(' ', '_')
|
||||
if normalized_id in MOCK_WEAPONS:
|
||||
weapon = MOCK_WEAPONS[normalized_id]
|
||||
self._cache.set(cache_key, weapon)
|
||||
return weapon
|
||||
|
||||
# Try partial match
|
||||
for mock_id, weapon in MOCK_WEAPONS.items():
|
||||
if normalized_id in mock_id or normalized_id in weapon.name.lower():
|
||||
self._cache.set(cache_key, weapon)
|
||||
return weapon
|
||||
|
||||
return None
|
||||
|
||||
async def get_armor_stats(self, item_id: str) -> Optional[ArmorStats]:
|
||||
"""
|
||||
Get detailed armor statistics.
|
||||
|
||||
Args:
|
||||
item_id: Armor item ID or name
|
||||
|
||||
Returns:
|
||||
ArmorStats object or None if not found
|
||||
"""
|
||||
cache_key = f"armor:{item_id}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Try API first
|
||||
data = await self._make_request(f'armor/{item_id}')
|
||||
|
||||
if data:
|
||||
try:
|
||||
protection = data.get('protection', {})
|
||||
armor = ArmorStats(
|
||||
name=data['name'],
|
||||
decay_pec=Decimal(str(data.get('decay_pec', 0))),
|
||||
protection={k: Decimal(str(v)) for k, v in protection.items()},
|
||||
slot=data.get('slot', 'body'),
|
||||
durability=data.get('durability', 10000),
|
||||
item_id=item_id
|
||||
)
|
||||
self._cache.set(cache_key, armor)
|
||||
return armor
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse armor data: {e}")
|
||||
|
||||
# Fallback to mock data
|
||||
if self.use_mock_fallback:
|
||||
normalized_id = item_id.lower().replace(' ', '_')
|
||||
if normalized_id in MOCK_ARMORS:
|
||||
armor = MOCK_ARMORS[normalized_id]
|
||||
self._cache.set(cache_key, armor)
|
||||
return armor
|
||||
|
||||
# Try partial match
|
||||
for mock_id, armor in MOCK_ARMORS.items():
|
||||
if normalized_id in mock_id or normalized_id in armor.name.lower():
|
||||
self._cache.set(cache_key, armor)
|
||||
return armor
|
||||
|
||||
return None
|
||||
|
||||
async def get_tool_stats(self, item_id: str) -> Optional[ToolStats]:
|
||||
"""
|
||||
Get detailed tool statistics.
|
||||
|
||||
Args:
|
||||
item_id: Tool item ID or name
|
||||
|
||||
Returns:
|
||||
ToolStats object or None if not found
|
||||
"""
|
||||
cache_key = f"tool:{item_id}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Try API first
|
||||
data = await self._make_request(f'tools/{item_id}')
|
||||
|
||||
if data:
|
||||
try:
|
||||
tool = ToolStats(
|
||||
name=data['name'],
|
||||
depth=Decimal(str(data.get('depth', 0))),
|
||||
radius=Decimal(str(data.get('radius', 0))),
|
||||
decay_pec=Decimal(str(data.get('decay_pec', 0))),
|
||||
tool_type=data.get('type', 'finder'),
|
||||
probe_cost=Decimal(str(data.get('probe_cost', 0.5))),
|
||||
item_id=item_id
|
||||
)
|
||||
self._cache.set(cache_key, tool)
|
||||
return tool
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse tool data: {e}")
|
||||
|
||||
# Fallback to mock data
|
||||
if self.use_mock_fallback:
|
||||
normalized_id = item_id.lower().replace(' ', '_')
|
||||
if normalized_id in MOCK_TOOLS:
|
||||
tool = MOCK_TOOLS[normalized_id]
|
||||
self._cache.set(cache_key, tool)
|
||||
return tool
|
||||
|
||||
# Try partial match
|
||||
for mock_id, tool in MOCK_TOOLS.items():
|
||||
if normalized_id in mock_id or normalized_id in tool.name.lower():
|
||||
self._cache.set(cache_key, tool)
|
||||
return tool
|
||||
|
||||
return None
|
||||
|
||||
async def get_market_markup(self, item_id: str) -> Optional[Decimal]:
|
||||
"""
|
||||
Get current market markup percentage for an item.
|
||||
|
||||
Args:
|
||||
item_id: Item ID or name
|
||||
|
||||
Returns:
|
||||
Markup percentage (e.g., 105.5 for 105.5%) or None
|
||||
"""
|
||||
cache_key = f"markup:{item_id}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
data = await self._make_request(f'markup/{item_id}')
|
||||
|
||||
if data and 'markup' in data:
|
||||
markup = Decimal(str(data['markup']))
|
||||
self._cache.set(cache_key, markup, ttl=300) # 5 min cache for market data
|
||||
return markup
|
||||
|
||||
return None
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data."""
|
||||
self._cache.clear()
|
||||
logger.info("Cache cleared")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the API client and cleanup resources."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_mock_weapons() -> Dict[str, WeaponStats]:
|
||||
"""Get all mock weapons."""
|
||||
return MOCK_WEAPONS.copy()
|
||||
|
||||
|
||||
def get_mock_armors() -> Dict[str, ArmorStats]:
|
||||
"""Get all mock armors."""
|
||||
return MOCK_ARMORS.copy()
|
||||
|
||||
|
||||
def get_mock_tools() -> Dict[str, ToolStats]:
|
||||
"""Get all mock tools."""
|
||||
return MOCK_TOOLS.copy()
|
||||
|
||||
|
||||
def calculate_dpp(damage: Decimal, decay_pec: Decimal, ammo_pec: Decimal) -> Decimal:
|
||||
"""
|
||||
Calculate Damage Per PEC (DPP).
|
||||
|
||||
DPP is a key efficiency metric in Entropia Universe.
|
||||
Higher DPP means more damage per PEC spent.
|
||||
|
||||
Args:
|
||||
damage: Weapon damage
|
||||
decay_pec: Decay per shot in PEC
|
||||
ammo_pec: Ammo cost per shot in PEC
|
||||
|
||||
Returns:
|
||||
DPP value
|
||||
"""
|
||||
total_cost_ped = (decay_pec + ammo_pec) / 100
|
||||
if total_cost_ped == 0:
|
||||
return Decimal("0")
|
||||
return damage / total_cost_ped
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Module Exports
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
'WeaponStats',
|
||||
'ArmorStats',
|
||||
'ToolStats',
|
||||
'EntropiaNexusAPI',
|
||||
'SimpleCache',
|
||||
'MOCK_WEAPONS',
|
||||
'MOCK_ARMORS',
|
||||
'MOCK_TOOLS',
|
||||
'get_mock_weapons',
|
||||
'get_mock_armors',
|
||||
'get_mock_tools',
|
||||
'calculate_dpp',
|
||||
]
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
# Self-Hosted Obsidian Alternative: Wiki.js
|
||||
|
||||
This guide sets up **Wiki.js** as your always-on documentation vault that syncs with your existing workflow.
|
||||
|
||||
## What is Wiki.js?
|
||||
|
||||
- **Web-based** Obsidian alternative (always accessible)
|
||||
- **Markdown native** (same format as Obsidian)
|
||||
- **Git sync** (integrates with your Gitea!)
|
||||
- **Self-hosted** in Docker via Portainer
|
||||
- **FREE** and open source
|
||||
|
||||
## Why Wiki.js over other options?
|
||||
|
||||
| Feature | Wiki.js | Obsidian Publish | Other Wikis |
|
||||
|---------|---------|------------------|-------------|
|
||||
| Self-hosted | ✅ Yes | ❌ No | Varies |
|
||||
| Markdown | ✅ Native | ✅ Yes | ⚠️ Partial |
|
||||
| Git sync | ✅ Yes | ❌ No | ❌ Rare |
|
||||
| Web editing | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||
| Free | ✅ Yes | ❌ $8/mo | Varies |
|
||||
| Gitea integration | ✅ Yes | ❌ No | ❌ No |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker VM with Portainer running
|
||||
- At least 2GB RAM available
|
||||
- (Optional) Gitea repository for sync
|
||||
|
||||
---
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Step 1: Create the Stack in Portainer
|
||||
|
||||
1. **Open Portainer** (http://your-docker-vm:9000)
|
||||
2. Go to **Stacks** → **Add stack**
|
||||
3. **Name:** `wikijs-vault`
|
||||
4. **Build method:** Web editor
|
||||
5. **Copy-paste the YAML below:**
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
wikidb:
|
||||
image: postgres:15-alpine
|
||||
container_name: wikijs-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: wiki
|
||||
POSTGRES_PASSWORD: wikijsrocks
|
||||
POSTGRES_USER: wikijs
|
||||
volumes:
|
||||
- wikijs-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- wikijs-network
|
||||
|
||||
wiki:
|
||||
image: ghcr.io/requarks/wiki:2
|
||||
container_name: wikijs
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- wikidb
|
||||
environment:
|
||||
DB_TYPE: postgres
|
||||
DB_HOST: wikidb
|
||||
DB_PORT: 5432
|
||||
DB_USER: wikijs
|
||||
DB_PASS: wikijsrocks
|
||||
DB_NAME: wiki
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- wikijs-data:/wiki/data
|
||||
networks:
|
||||
- wikijs-network
|
||||
|
||||
networks:
|
||||
wikijs-network:
|
||||
|
||||
volumes:
|
||||
wikijs-db-data:
|
||||
wikijs-data:
|
||||
```
|
||||
|
||||
6. **Click "Deploy the stack"**
|
||||
|
||||
### Step 2: Initial Setup (2 minutes)
|
||||
|
||||
1. **Wait 30 seconds** for containers to start
|
||||
2. **Open Wiki.js:** http://your-docker-vm-ip:3001
|
||||
3. **Create admin account**
|
||||
4. **Choose "Local" authentication** (or LDAP if you have it)
|
||||
|
||||
### Step 3: Configure Git Sync (Connect to Gitea)
|
||||
|
||||
This syncs your wiki with a Git repository (bidirectional!)
|
||||
|
||||
1. **In Wiki.js:** Administration → Git → Enable
|
||||
2. **Set these values:**
|
||||
- **Git URL:** `http://192.168.5.30:3000/impulsivefps/lemontropia-wiki.git`
|
||||
- **Branch:** `main`
|
||||
- **Username:** `impulsivefps`
|
||||
- **Password/Token:** Your Gitea token
|
||||
- **Sync Direction:** Bidirectional
|
||||
3. **Click "Apply"**
|
||||
|
||||
### Step 4: Create Gitea Repository
|
||||
|
||||
1. **Open Gitea** (http://192.168.5.30:3000)
|
||||
2. **New Repository:**
|
||||
- Name: `lemontropia-wiki`
|
||||
- Description: "Lemontropia Suite Documentation"
|
||||
- Private: Yes
|
||||
3. **Generate token** in Gitea settings for Wiki.js
|
||||
|
||||
---
|
||||
|
||||
## Using Your Wiki
|
||||
|
||||
### From Browser (Anywhere)
|
||||
- **URL:** http://your-docker-vm:3001
|
||||
- **Edit** directly in web interface
|
||||
- **Markdown** support with live preview
|
||||
- **Search** across all content
|
||||
|
||||
### From Obsidian Desktop
|
||||
1. **Clone the repo:** `git clone http://192.168.5.30:3000/impulsivefps/lemontropia-wiki.git`
|
||||
2. **Open folder** as vault in Obsidian
|
||||
3. **Edit normally**
|
||||
4. **Sync:** Git commit → Auto-syncs to Wiki.js!
|
||||
|
||||
### Best of Both Worlds
|
||||
- **Quick edits** → Use web interface (always available)
|
||||
- **Heavy writing** → Use Obsidian desktop + git
|
||||
- **Mobile access** → Web interface works on phone/tablet
|
||||
- **Offline work** → Obsidian desktop works offline, sync later
|
||||
|
||||
---
|
||||
|
||||
## Integration with Lemontropia Suite
|
||||
|
||||
Your app can now:
|
||||
1. **Write** to Obsidian vault locally
|
||||
2. **Git commit/push** → Syncs to Gitea
|
||||
3. **Wiki.js pulls** → Documentation live on web
|
||||
4. **Access anywhere** → http://docker-vm:3001
|
||||
|
||||
---
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
Wiki.js data is stored in:
|
||||
- **Docker volumes** (auto-backed up with VM)
|
||||
- **Git repository** (in Gitea, already backed up)
|
||||
|
||||
Your docs are safe! 🎉
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Can't access on port 3001?
|
||||
Check firewall: `sudo ufw allow 3001/tcp`
|
||||
|
||||
### Git sync not working?
|
||||
- Verify Gitea token has "repo" permissions
|
||||
- Check URL format (http vs https)
|
||||
- Review Wiki.js logs in Portainer
|
||||
|
||||
### Obsidian sync conflicts?
|
||||
- Always pull before editing in Obsidian
|
||||
- Use "Sync" button in Wiki.js before web edits
|
||||
|
||||
---
|
||||
|
||||
**Questions? The Lemontropia Suite docs will live here!** 🍋📚
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Wiki.js Self-Hosted Documentation Vault
|
||||
# Portainer Stack for Docker VM
|
||||
# Replaces/extends Obsidian with always-on web access
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
wikidb:
|
||||
image: postgres:15-alpine
|
||||
container_name: wikijs-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: wiki
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-wikijsrocks}
|
||||
POSTGRES_USER: wikijs
|
||||
logging:
|
||||
driver: "none"
|
||||
volumes:
|
||||
- wikijs-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- wikijs-network
|
||||
|
||||
wiki:
|
||||
image: ghcr.io/requarks/wiki:2
|
||||
container_name: wikijs
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- wikidb
|
||||
environment:
|
||||
DB_TYPE: postgres
|
||||
DB_HOST: wikidb
|
||||
DB_PORT: 5432
|
||||
DB_USER: wikijs
|
||||
DB_PASS: ${DB_PASSWORD:-wikijsrocks}
|
||||
DB_NAME: wiki
|
||||
# Optional: Git sync to your Gitea
|
||||
# GIT_URL: http://192.168.5.30:3000/impulsivefps/lemontropia-wiki.git
|
||||
# GIT_BRANCH: main
|
||||
# GIT_USERNAME: impulsivefps
|
||||
# GIT_PASSWORD: ${GIT_TOKEN}
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- wikijs-data:/wiki/data
|
||||
networks:
|
||||
- wikijs-network
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
networks:
|
||||
wikijs-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
wikijs-db-data:
|
||||
wikijs-data:
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Add to your EXISTING Portainer stack
|
||||
# Just add this service to your current docker-compose
|
||||
|
||||
wikijs:
|
||||
image: ghcr.io/requarks/wiki:2
|
||||
container_name: docs_wikijs
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_TYPE: postgres
|
||||
DB_HOST: docs_postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: docs
|
||||
DB_PASS: docs12345
|
||||
DB_NAME: wikijs
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- /opt/homelab-docs/data/wikijs:/wiki/data
|
||||
networks:
|
||||
- docs
|
||||
depends_on:
|
||||
- postgres
|
||||
69
main.py
69
main.py
|
|
@ -23,6 +23,7 @@ sys.path.insert(0, str(core_dir))
|
|||
from core.database import DatabaseManager
|
||||
from core.project_manager import ProjectManager, ProjectData, SessionData, LootEvent
|
||||
from core.log_watcher import LogWatcher, MockLogGenerator
|
||||
from ui.hud_overlay import HUDOverlay
|
||||
|
||||
# Configure logging for user visibility
|
||||
logging.basicConfig(
|
||||
|
|
@ -54,6 +55,7 @@ class LemontropiaApp:
|
|||
self.pm = ProjectManager(self.db)
|
||||
self.watcher = None
|
||||
self._running = False
|
||||
self.hud = None
|
||||
|
||||
# Initialize database
|
||||
logger.info("🍋 Initializing Lemontropia Suite...")
|
||||
|
|
@ -63,9 +65,30 @@ class LemontropiaApp:
|
|||
|
||||
logger.info("✅ Database ready")
|
||||
|
||||
# Initialize HUD overlay (Qt app must be created first)
|
||||
self._init_hud()
|
||||
|
||||
# Ensure test data exists
|
||||
self._ensure_mock_data()
|
||||
|
||||
def _init_hud(self):
|
||||
"""Initialize the HUD overlay."""
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
# Create QApplication if not exists
|
||||
self.qt_app = QApplication.instance()
|
||||
if self.qt_app is None:
|
||||
self.qt_app = QApplication(sys.argv)
|
||||
self.qt_app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Create HUD
|
||||
self.hud = HUDOverlay()
|
||||
logger.info("✅ HUD overlay ready")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ HUD initialization failed: {e}")
|
||||
self.hud = None
|
||||
|
||||
def _ensure_mock_data(self):
|
||||
"""Create mock chat.log if it doesn't exist."""
|
||||
test_data_dir = Path(__file__).parent / "test-data"
|
||||
|
|
@ -193,6 +216,12 @@ class LemontropiaApp:
|
|||
session = self.pm.start_session(project.id, notes=session_notes)
|
||||
print(f"✅ SESSION started: ID {session.id}")
|
||||
|
||||
# Show HUD if available
|
||||
if self.hud:
|
||||
self.hud.show()
|
||||
self.hud.start_session(weapon="Unknown", loadout="Default")
|
||||
logger.info("✅ HUD displayed")
|
||||
|
||||
# Setup log watcher - use real log or mock based on .env
|
||||
use_mock = os.getenv('USE_MOCK_DATA', 'true').lower() in ('true', '1', 'yes')
|
||||
log_path = os.getenv('EU_CHAT_LOG_PATH', '')
|
||||
|
|
@ -213,13 +242,14 @@ class LemontropiaApp:
|
|||
"""Handle log events."""
|
||||
if event.event_type == 'loot':
|
||||
item_name = event.data.get('item_name', 'Unknown')
|
||||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||||
# Skip Universal Ammo - it's converted shrapnel, not loot
|
||||
if item_name == 'Universal Ammo':
|
||||
return
|
||||
loot = LootEvent(
|
||||
item_name=item_name,
|
||||
quantity=event.data.get('quantity', 1),
|
||||
value_ped=event.data.get('value_ped', Decimal('0.0')),
|
||||
value_ped=value_ped,
|
||||
event_type='regular',
|
||||
raw_log_line=event.raw_line
|
||||
)
|
||||
|
|
@ -227,18 +257,30 @@ class LemontropiaApp:
|
|||
stats['loot'] += 1
|
||||
stats['total_ped'] += loot.value_ped
|
||||
print(f" 💰 Loot: {loot.item_name} x{loot.quantity} ({loot.value_ped} PED)")
|
||||
# Update HUD
|
||||
if self.hud:
|
||||
self.hud.on_loot_event(item_name, value_ped)
|
||||
|
||||
elif event.event_type == 'global':
|
||||
stats['globals'] += 1
|
||||
print(f" 🌍 GLOBAL: {event.data.get('player_name')} found {event.data.get('value_ped')} PED!")
|
||||
if self.hud:
|
||||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||||
self.hud.on_global(value_ped)
|
||||
|
||||
elif event.event_type == 'personal_global':
|
||||
stats['personal_globals'] += 1
|
||||
print(f" 🎉🎉🎉 YOUR GLOBAL: {event.data.get('player_name')} killed {event.data.get('creature')} for {event.data.get('value_ped')} PED!!! 🎉🎉🎉")
|
||||
if self.hud:
|
||||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||||
self.hud.on_global(value_ped)
|
||||
|
||||
elif event.event_type == 'hof':
|
||||
stats['hofs'] += 1
|
||||
print(f" 🏆 HALL OF FAME: {event.data.get('value_ped')} PED!")
|
||||
if self.hud:
|
||||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||||
self.hud.on_hof(value_ped)
|
||||
|
||||
elif event.event_type == 'skill':
|
||||
stats['skills'] += 1
|
||||
|
|
@ -257,16 +299,25 @@ class LemontropiaApp:
|
|||
print(f" 💔 ENHANCER BROKEN: {event.data.get('enhancer_type')} on {event.data.get('weapon')}!")
|
||||
|
||||
elif event.event_type == 'damage_dealt':
|
||||
stats['damage_dealt'] += 1
|
||||
print(f" 💥 Damage Dealt: {event.data.get('damage')} pts")
|
||||
damage = event.data.get('damage', 0)
|
||||
stats['damage_dealt'] += damage
|
||||
print(f" 💥 Damage Dealt: {damage} pts")
|
||||
if self.hud:
|
||||
self.hud.on_damage_dealt(float(damage))
|
||||
|
||||
elif event.event_type == 'critical_hit':
|
||||
stats['damage_dealt'] += 1 # Count as damage dealt too
|
||||
print(f" 💀 CRITICAL: {event.data.get('damage')} pts")
|
||||
damage = event.data.get('damage', 0)
|
||||
stats['damage_dealt'] += damage # Count as damage dealt too
|
||||
print(f" 💀 CRITICAL: {damage} pts")
|
||||
if self.hud:
|
||||
self.hud.on_damage_dealt(float(damage))
|
||||
|
||||
elif event.event_type == 'damage_taken':
|
||||
stats['damage_taken'] += 1
|
||||
print(f" 🛡️ Damage Taken: {event.data.get('damage')} pts")
|
||||
damage = event.data.get('damage', 0)
|
||||
stats['damage_taken'] += damage
|
||||
print(f" 🛡️ Damage Taken: {damage} pts")
|
||||
if self.hud:
|
||||
self.hud.on_damage_taken(float(damage))
|
||||
|
||||
elif event.event_type == 'evade':
|
||||
stats['evades'] += 1
|
||||
|
|
@ -304,6 +355,10 @@ class LemontropiaApp:
|
|||
|
||||
await self.watcher.stop()
|
||||
|
||||
# End HUD session if running
|
||||
if self.hud:
|
||||
self.hud.end_session()
|
||||
|
||||
# End session
|
||||
self.pm.end_session(session.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,588 @@
|
|||
"""
|
||||
Unit tests for Entropia Nexus API client.
|
||||
|
||||
Run with: python -m pytest tests/test_nexus_api.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
|
||||
# Import the module under test
|
||||
try:
|
||||
from core.nexus_api import (
|
||||
WeaponStats,
|
||||
ArmorStats,
|
||||
ToolStats,
|
||||
EntropiaNexusAPI,
|
||||
SimpleCache,
|
||||
MOCK_WEAPONS,
|
||||
MOCK_ARMORS,
|
||||
MOCK_TOOLS,
|
||||
get_mock_weapons,
|
||||
get_mock_armors,
|
||||
get_mock_tools,
|
||||
calculate_dpp,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from core.nexus_api import (
|
||||
WeaponStats,
|
||||
ArmorStats,
|
||||
ToolStats,
|
||||
EntropiaNexusAPI,
|
||||
SimpleCache,
|
||||
MOCK_WEAPONS,
|
||||
MOCK_ARMORS,
|
||||
MOCK_TOOLS,
|
||||
get_mock_weapons,
|
||||
get_mock_armors,
|
||||
get_mock_tools,
|
||||
calculate_dpp,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api():
|
||||
"""Create API client in mock mode."""
|
||||
return EntropiaNexusAPI(mock_mode=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_with_fallback():
|
||||
"""Create API client with mock fallback enabled."""
|
||||
return EntropiaNexusAPI(use_mock_fallback=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
"""Create a fresh cache instance."""
|
||||
return SimpleCache(default_ttl=3600)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WeaponStats Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestWeaponStats:
|
||||
"""Test WeaponStats data class."""
|
||||
|
||||
def test_weapon_creation(self):
|
||||
"""Test basic weapon stats creation."""
|
||||
weapon = WeaponStats(
|
||||
name="Test Weapon",
|
||||
damage=Decimal("10.0"),
|
||||
decay_pec=Decimal("0.5"),
|
||||
ammo_pec=Decimal("1.5"),
|
||||
dpp=Decimal("5.0"),
|
||||
range=50,
|
||||
attacks_per_min=60
|
||||
)
|
||||
|
||||
assert weapon.name == "Test Weapon"
|
||||
assert weapon.damage == Decimal("10.0")
|
||||
assert weapon.decay_pec == Decimal("0.5")
|
||||
assert weapon.ammo_pec == Decimal("1.5")
|
||||
assert weapon.dpp == Decimal("5.0")
|
||||
assert weapon.range == 50
|
||||
assert weapon.attacks_per_min == 60
|
||||
|
||||
def test_total_cost_calculation(self):
|
||||
"""Test total cost per shot is calculated correctly."""
|
||||
weapon = WeaponStats(
|
||||
name="Test",
|
||||
damage=Decimal("10.0"),
|
||||
decay_pec=Decimal("0.5"),
|
||||
ammo_pec=Decimal("1.5"),
|
||||
dpp=Decimal("5.0")
|
||||
)
|
||||
|
||||
expected_total = Decimal("2.0")
|
||||
assert weapon.total_cost_pec == expected_total
|
||||
|
||||
def test_cost_per_hour_calculation(self):
|
||||
"""Test cost per hour calculation."""
|
||||
weapon = WeaponStats(
|
||||
name="Test",
|
||||
damage=Decimal("10.0"),
|
||||
decay_pec=Decimal("1.0"), # 1 PEC
|
||||
ammo_pec=Decimal("1.0"), # 1 PEC
|
||||
dpp=Decimal("5.0"),
|
||||
attacks_per_min=60 # 60 shots/min = 3600/hour
|
||||
)
|
||||
|
||||
# Total 2 PEC per shot * 3600 shots = 7200 PEC = 72 PED
|
||||
expected_cost = Decimal("72.0")
|
||||
assert weapon.calculate_cost_per_hour() == expected_cost
|
||||
|
||||
def test_cost_per_hour_zero_attacks(self):
|
||||
"""Test cost per hour with zero attacks per minute."""
|
||||
weapon = WeaponStats(
|
||||
name="Test",
|
||||
damage=Decimal("10.0"),
|
||||
decay_pec=Decimal("1.0"),
|
||||
ammo_pec=Decimal("1.0"),
|
||||
dpp=Decimal("5.0"),
|
||||
attacks_per_min=0
|
||||
)
|
||||
|
||||
assert weapon.calculate_cost_per_hour() == Decimal("0")
|
||||
|
||||
def test_to_dict_and_from_dict(self):
|
||||
"""Test serialization roundtrip."""
|
||||
original = WeaponStats(
|
||||
name="Test Weapon",
|
||||
damage=Decimal("10.0"),
|
||||
decay_pec=Decimal("0.5"),
|
||||
ammo_pec=Decimal("1.5"),
|
||||
dpp=Decimal("5.0"),
|
||||
range=50,
|
||||
attacks_per_min=60,
|
||||
item_id="test_weapon"
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = WeaponStats.from_dict(data)
|
||||
|
||||
assert restored.name == original.name
|
||||
assert restored.damage == original.damage
|
||||
assert restored.decay_pec == original.decay_pec
|
||||
assert restored.ammo_pec == original.ammo_pec
|
||||
assert restored.dpp == original.dpp
|
||||
assert restored.range == original.range
|
||||
assert restored.attacks_per_min == original.attacks_per_min
|
||||
assert restored.item_id == original.item_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ArmorStats Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestArmorStats:
|
||||
"""Test ArmorStats data class."""
|
||||
|
||||
def test_armor_creation(self):
|
||||
"""Test basic armor stats creation."""
|
||||
armor = ArmorStats(
|
||||
name="Test Armor",
|
||||
decay_pec=Decimal("0.5"),
|
||||
protection={
|
||||
"impact": Decimal("10.0"),
|
||||
"cut": Decimal("8.0"),
|
||||
"burn": Decimal("5.0")
|
||||
},
|
||||
slot="body",
|
||||
durability=5000
|
||||
)
|
||||
|
||||
assert armor.name == "Test Armor"
|
||||
assert armor.decay_pec == Decimal("0.5")
|
||||
assert armor.protection["impact"] == Decimal("10.0")
|
||||
assert armor.slot == "body"
|
||||
assert armor.durability == 5000
|
||||
|
||||
def test_get_protection(self):
|
||||
"""Test getting protection for specific damage type."""
|
||||
armor = ArmorStats(
|
||||
name="Test",
|
||||
decay_pec=Decimal("0.5"),
|
||||
protection={"impact": Decimal("10.0"), "cut": Decimal("8.0")}
|
||||
)
|
||||
|
||||
assert armor.get_protection("impact") == Decimal("10.0")
|
||||
assert armor.get_protection("cut") == Decimal("8.0")
|
||||
assert armor.get_protection("nonexistent") == Decimal("0")
|
||||
|
||||
def test_get_total_protection(self):
|
||||
"""Test total protection calculation."""
|
||||
armor = ArmorStats(
|
||||
name="Test",
|
||||
decay_pec=Decimal("0.5"),
|
||||
protection={
|
||||
"impact": Decimal("10.0"),
|
||||
"cut": Decimal("8.0"),
|
||||
"burn": Decimal("5.0")
|
||||
}
|
||||
)
|
||||
|
||||
assert armor.get_total_protection() == Decimal("23.0")
|
||||
|
||||
def test_to_dict_and_from_dict(self):
|
||||
"""Test serialization roundtrip."""
|
||||
original = ArmorStats(
|
||||
name="Test Armor",
|
||||
decay_pec=Decimal("0.5"),
|
||||
protection={"impact": Decimal("10.0")},
|
||||
slot="head",
|
||||
durability=3000,
|
||||
item_id="test_armor"
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = ArmorStats.from_dict(data)
|
||||
|
||||
assert restored.name == original.name
|
||||
assert restored.decay_pec == original.decay_pec
|
||||
assert restored.protection == original.protection
|
||||
assert restored.slot == original.slot
|
||||
assert restored.durability == original.durability
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ToolStats Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestToolStats:
|
||||
"""Test ToolStats data class."""
|
||||
|
||||
def test_tool_creation(self):
|
||||
"""Test basic tool stats creation."""
|
||||
tool = ToolStats(
|
||||
name="Test Finder",
|
||||
depth=Decimal("200.0"),
|
||||
radius=Decimal("25.0"),
|
||||
decay_pec=Decimal("0.3"),
|
||||
tool_type="finder",
|
||||
probe_cost=Decimal("0.5")
|
||||
)
|
||||
|
||||
assert tool.name == "Test Finder"
|
||||
assert tool.depth == Decimal("200.0")
|
||||
assert tool.radius == Decimal("25.0")
|
||||
assert tool.decay_pec == Decimal("0.3")
|
||||
assert tool.tool_type == "finder"
|
||||
assert tool.probe_cost == Decimal("0.5")
|
||||
|
||||
def test_cost_per_drop_calculation(self):
|
||||
"""Test cost per drop calculation."""
|
||||
tool = ToolStats(
|
||||
name="Test",
|
||||
depth=Decimal("200.0"),
|
||||
radius=Decimal("25.0"),
|
||||
decay_pec=Decimal("20.0"), # 20 PEC
|
||||
probe_cost=Decimal("0.5") # 0.5 PED
|
||||
)
|
||||
|
||||
# 20 PEC = 0.2 PED + 0.5 PED probe = 0.7 PED
|
||||
expected_cost = Decimal("0.7")
|
||||
assert tool.calculate_cost_per_drop() == expected_cost
|
||||
|
||||
def test_to_dict_and_from_dict(self):
|
||||
"""Test serialization roundtrip."""
|
||||
original = ToolStats(
|
||||
name="Test Tool",
|
||||
depth=Decimal("200.0"),
|
||||
radius=Decimal("25.0"),
|
||||
decay_pec=Decimal("0.3"),
|
||||
tool_type="finder",
|
||||
probe_cost=Decimal("0.5"),
|
||||
item_id="test_tool"
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = ToolStats.from_dict(data)
|
||||
|
||||
assert restored.name == original.name
|
||||
assert restored.depth == original.depth
|
||||
assert restored.radius == original.radius
|
||||
assert restored.decay_pec == original.decay_pec
|
||||
assert restored.tool_type == original.tool_type
|
||||
assert restored.probe_cost == original.probe_cost
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SimpleCache Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSimpleCache:
|
||||
"""Test SimpleCache implementation."""
|
||||
|
||||
def test_cache_set_and_get(self, cache):
|
||||
"""Test basic cache operations."""
|
||||
cache.set("key1", "value1")
|
||||
assert cache.get("key1") == "value1"
|
||||
|
||||
def test_cache_miss(self, cache):
|
||||
"""Test cache miss returns None."""
|
||||
assert cache.get("nonexistent") is None
|
||||
|
||||
def test_cache_delete(self, cache):
|
||||
"""Test cache deletion."""
|
||||
cache.set("key1", "value1")
|
||||
cache.delete("key1")
|
||||
assert cache.get("key1") is None
|
||||
|
||||
def test_cache_clear(self, cache):
|
||||
"""Test cache clear."""
|
||||
cache.set("key1", "value1")
|
||||
cache.set("key2", "value2")
|
||||
cache.clear()
|
||||
assert cache.get("key1") is None
|
||||
assert cache.get("key2") is None
|
||||
assert cache.keys() == []
|
||||
|
||||
def test_cache_keys(self, cache):
|
||||
"""Test getting cache keys."""
|
||||
cache.set("key1", "value1")
|
||||
cache.set("key2", "value2")
|
||||
keys = cache.keys()
|
||||
assert "key1" in keys
|
||||
assert "key2" in keys
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EntropiaNexusAPI Tests - Mock Mode
|
||||
# =============================================================================
|
||||
|
||||
class TestEntropiaNexusAPIMock:
|
||||
"""Test API client in mock mode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_items_mock(self, mock_api):
|
||||
"""Test searching items in mock mode."""
|
||||
results = await mock_api.search_items("opalo")
|
||||
|
||||
assert len(results) > 0
|
||||
assert any("opalo" in r['name'].lower() for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_items_type_filter(self, mock_api):
|
||||
"""Test searching with type filter."""
|
||||
results = await mock_api.search_items("", item_type="weapon")
|
||||
|
||||
assert all(r['type'] == 'weapon' for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_weapon_stats_mock(self, mock_api):
|
||||
"""Test getting weapon stats in mock mode."""
|
||||
weapon = await mock_api.get_weapon_stats("sollomate_opalo")
|
||||
|
||||
assert weapon is not None
|
||||
assert weapon.name == "Sollomate Opalo"
|
||||
assert weapon.damage > 0
|
||||
assert weapon.dpp > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_weapon_stats_partial_match(self, mock_api):
|
||||
"""Test getting weapon by partial name match."""
|
||||
weapon = await mock_api.get_weapon_stats("opalo")
|
||||
|
||||
assert weapon is not None
|
||||
assert "Opalo" in weapon.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_weapon_stats_not_found(self, mock_api):
|
||||
"""Test getting non-existent weapon."""
|
||||
weapon = await mock_api.get_weapon_stats("nonexistent_weapon_xyz")
|
||||
|
||||
assert weapon is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_armor_stats_mock(self, mock_api):
|
||||
"""Test getting armor stats in mock mode."""
|
||||
armor = await mock_api.get_armor_stats("pixie")
|
||||
|
||||
assert armor is not None
|
||||
assert armor.name == "Pixie"
|
||||
assert len(armor.protection) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_armor_stats_partial_match(self, mock_api):
|
||||
"""Test getting armor by partial name match."""
|
||||
armor = await mock_api.get_armor_stats("shog")
|
||||
|
||||
assert armor is not None
|
||||
assert "Shogun" in armor.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_stats_mock(self, mock_api):
|
||||
"""Test getting tool stats in mock mode."""
|
||||
tool = await mock_api.get_tool_stats("ziplex_z1")
|
||||
|
||||
assert tool is not None
|
||||
assert "Ziplex" in tool.name
|
||||
assert tool.depth > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_usage(self, mock_api):
|
||||
"""Test that results are cached."""
|
||||
# First call
|
||||
weapon1 = await mock_api.get_weapon_stats("sollomate_opalo")
|
||||
|
||||
# Second call should come from cache
|
||||
weapon2 = await mock_api.get_weapon_stats("sollomate_opalo")
|
||||
|
||||
# Same object from cache
|
||||
assert weapon1 is weapon2
|
||||
assert "weapon:sollomate_opalo" in mock_api._cache.keys()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_cache(self, mock_api):
|
||||
"""Test clearing cache."""
|
||||
await mock_api.get_weapon_stats("sollomate_opalo")
|
||||
mock_api.clear_cache()
|
||||
|
||||
assert mock_api._cache.keys() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager(self):
|
||||
"""Test async context manager."""
|
||||
async with EntropiaNexusAPI(mock_mode=True) as api:
|
||||
weapon = await api.get_weapon_stats("sollomate_opalo")
|
||||
assert weapon is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mock Data Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestMockData:
|
||||
"""Test mock data integrity."""
|
||||
|
||||
def test_mock_weapons_count(self):
|
||||
"""Test we have expected number of mock weapons."""
|
||||
assert len(MOCK_WEAPONS) == 6
|
||||
|
||||
def test_mock_armors_count(self):
|
||||
"""Test we have expected number of mock armors."""
|
||||
assert len(MOCK_ARMORS) == 5
|
||||
|
||||
def test_mock_tools_count(self):
|
||||
"""Test we have expected number of mock tools."""
|
||||
assert len(MOCK_TOOLS) == 4
|
||||
|
||||
def test_mock_weapon_dpp_calculated(self):
|
||||
"""Test mock weapons have reasonable DPP values."""
|
||||
for weapon_id, weapon in MOCK_WEAPONS.items():
|
||||
assert weapon.dpp > 0, f"{weapon_id} has invalid DPP"
|
||||
assert weapon.damage > 0, f"{weapon_id} has invalid damage"
|
||||
assert weapon.decay_pec >= 0, f"{weapon_id} has invalid decay"
|
||||
|
||||
def test_mock_armor_protection(self):
|
||||
"""Test mock armors have protection values."""
|
||||
for armor_id, armor in MOCK_ARMORS.items():
|
||||
assert len(armor.protection) > 0, f"{armor_id} has no protection"
|
||||
assert all(v >= 0 for v in armor.protection.values()), f"{armor_id} has negative protection"
|
||||
|
||||
def test_mock_tool_depth(self):
|
||||
"""Test mock tools have depth values."""
|
||||
for tool_id, tool in MOCK_TOOLS.items():
|
||||
if tool.tool_type == "finder":
|
||||
assert tool.depth > 0, f"{tool_id} finder has no depth"
|
||||
assert tool.radius > 0, f"{tool_id} finder has no radius"
|
||||
|
||||
def test_get_mock_weapons(self):
|
||||
"""Test get_mock_weapons function."""
|
||||
weapons = get_mock_weapons()
|
||||
assert len(weapons) == 6
|
||||
assert "sollomate_opalo" in weapons
|
||||
|
||||
def test_get_mock_armors(self):
|
||||
"""Test get_mock_armors function."""
|
||||
armors = get_mock_armors()
|
||||
assert len(armors) == 5
|
||||
assert "pixie" in armors
|
||||
|
||||
def test_get_mock_tools(self):
|
||||
"""Test get_mock_tools function."""
|
||||
tools = get_mock_tools()
|
||||
assert len(tools) == 4
|
||||
assert "ziplex_z1" in tools
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Function Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Test utility functions."""
|
||||
|
||||
def test_calculate_dpp(self):
|
||||
"""Test DPP calculation."""
|
||||
damage = Decimal("10.0")
|
||||
decay = Decimal("1.0") # 1 PEC
|
||||
ammo = Decimal("1.0") # 1 PEC
|
||||
|
||||
# Total cost = 2 PEC = 0.02 PED
|
||||
# DPP = 10 / 0.02 = 500
|
||||
dpp = calculate_dpp(damage, decay, ammo)
|
||||
assert dpp == Decimal("500")
|
||||
|
||||
def test_calculate_dpp_zero_cost(self):
|
||||
"""Test DPP calculation with zero cost."""
|
||||
dpp = calculate_dpp(Decimal("10.0"), Decimal("0"), Decimal("0"))
|
||||
assert dpp == Decimal("0")
|
||||
|
||||
def test_calculate_dpp_matches_mock_weapons(self):
|
||||
"""Test DPP calculation matches stored DPP values."""
|
||||
for weapon in MOCK_WEAPONS.values():
|
||||
calculated = calculate_dpp(
|
||||
weapon.damage,
|
||||
weapon.decay_pec,
|
||||
weapon.ammo_pec
|
||||
)
|
||||
# Allow small rounding differences
|
||||
diff = abs(calculated - weapon.dpp)
|
||||
assert diff < Decimal("0.1"), f"DPP mismatch for {weapon.name}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for the API client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_workflow_hunting(self):
|
||||
"""Test complete hunting setup workflow."""
|
||||
async with EntropiaNexusAPI(mock_mode=True) as api:
|
||||
# Search for weapon
|
||||
results = await api.search_items("opalo", item_type="weapon")
|
||||
assert len(results) > 0
|
||||
|
||||
# Get weapon details
|
||||
weapon_id = results[0]['id']
|
||||
weapon = await api.get_weapon_stats(weapon_id)
|
||||
assert weapon is not None
|
||||
|
||||
# Get armor
|
||||
armor = await api.get_armor_stats("pixie")
|
||||
assert armor is not None
|
||||
|
||||
# Calculate costs
|
||||
weapon_cost = weapon.calculate_cost_per_hour()
|
||||
assert weapon_cost > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_workflow_mining(self):
|
||||
"""Test complete mining setup workflow."""
|
||||
async with EntropiaNexusAPI(mock_mode=True) as api:
|
||||
# Get finder
|
||||
finder = await api.get_tool_stats("ziplex_z1")
|
||||
assert finder is not None
|
||||
|
||||
# Get extractor
|
||||
extractor = await api.get_tool_stats("ore_extractor_md1")
|
||||
assert extractor is not None
|
||||
|
||||
# Calculate costs
|
||||
finder_cost = finder.calculate_cost_per_drop()
|
||||
extractor_cost = extractor.calculate_cost_per_drop()
|
||||
|
||||
assert finder_cost > 0
|
||||
assert extractor_cost > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Tests
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -10,6 +10,11 @@ from datetime import datetime, timedelta
|
|||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Windows-specific imports for click-through support
|
||||
if sys.platform == 'win32':
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QFrame, QSizePolicy
|
||||
|
|
@ -112,6 +117,7 @@ class HUDOverlay(QWidget):
|
|||
# Session tracking
|
||||
self._session_start: Optional[datetime] = None
|
||||
self._stats = HUDStats()
|
||||
self.session_active = False # Public flag for session state
|
||||
|
||||
# Drag state
|
||||
self._dragging = False
|
||||
|
|
@ -387,11 +393,12 @@ class HUDOverlay(QWidget):
|
|||
self._dragging = True
|
||||
self._drag_offset = event.pos()
|
||||
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||||
self._enable_click_through(False) # Disable click-through for dragging
|
||||
event.accept()
|
||||
else:
|
||||
# Pass through to underlying window
|
||||
event.ignore()
|
||||
# Enable click-through and pass to underlying window
|
||||
self._enable_click_through(True)
|
||||
event.ignore()
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||||
"""Handle mouse move - drag window if in drag mode."""
|
||||
|
|
@ -412,6 +419,8 @@ class HUDOverlay(QWidget):
|
|||
self._dragging = False
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self._save_position()
|
||||
# Re-enable click-through after drag
|
||||
self._enable_click_through(True)
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
|
|
@ -427,11 +436,46 @@ class HUDOverlay(QWidget):
|
|||
|
||||
When enabled, mouse events pass through to the window below.
|
||||
When disabled (Ctrl held), window captures mouse events for dragging.
|
||||
|
||||
On Windows: Uses WinAPI for proper click-through support.
|
||||
On other platforms: Uses Qt's WA_TransparentForMouseEvents.
|
||||
"""
|
||||
if enable:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
if sys.platform == 'win32':
|
||||
self._set_click_through_win32(enable)
|
||||
else:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||
# Use Qt's built-in for non-Windows platforms
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enable)
|
||||
|
||||
def _set_click_through_win32(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable/disable click-through on Windows using WinAPI.
|
||||
|
||||
Uses SetWindowLongW to modify the window's extended style flags:
|
||||
- WS_EX_TRANSPARENT (0x00000020): Allows mouse events to pass through
|
||||
- WS_EX_LAYERED (0x00080000): Required for transparency effects
|
||||
|
||||
Args:
|
||||
enabled: True to enable click-through, False to capture mouse events
|
||||
"""
|
||||
GWL_EXSTYLE = -20
|
||||
WS_EX_TRANSPARENT = 0x00000020
|
||||
WS_EX_LAYERED = 0x00080000
|
||||
|
||||
try:
|
||||
hwnd = self.winId().__int__()
|
||||
|
||||
# Get current extended style
|
||||
style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
|
||||
|
||||
if enabled:
|
||||
style |= WS_EX_TRANSPARENT | WS_EX_LAYERED
|
||||
else:
|
||||
style &= ~(WS_EX_TRANSPARENT | WS_EX_LAYERED)
|
||||
|
||||
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style)
|
||||
except Exception:
|
||||
# Fallback to Qt method if WinAPI fails
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enabled)
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
"""Handle key press - detect Ctrl for drag mode."""
|
||||
|
|
@ -455,10 +499,18 @@ class HUDOverlay(QWidget):
|
|||
# SESSION MANAGEMENT
|
||||
# ========================================================================
|
||||
|
||||
def start_session(self) -> None:
|
||||
"""Start a new hunting/mining/crafting session."""
|
||||
def start_session(self, weapon: str = "Unknown", loadout: str = "Default") -> None:
|
||||
"""Start a new hunting/mining/crafting session.
|
||||
|
||||
Args:
|
||||
weapon: Name of the current weapon
|
||||
loadout: Name of the current loadout
|
||||
"""
|
||||
self._session_start = datetime.now()
|
||||
self._stats = HUDStats() # Reset stats
|
||||
self._stats.current_weapon = weapon
|
||||
self._stats.current_loadout = loadout
|
||||
self.session_active = True
|
||||
self._timer.start(1000) # Update every second
|
||||
self._refresh_display()
|
||||
self.status_label.setText("● Live - Recording")
|
||||
|
|
@ -468,10 +520,89 @@ class HUDOverlay(QWidget):
|
|||
"""End the current session."""
|
||||
self._timer.stop()
|
||||
self._session_start = None
|
||||
self.session_active = False
|
||||
self._save_position() # Save final stats
|
||||
self.status_label.setText("○ Paused")
|
||||
self.status_label.setStyleSheet("font-size: 9px; color: #888888;")
|
||||
|
||||
# ========================================================================
|
||||
# EVENT HANDLERS (Called from LogWatcher)
|
||||
# ========================================================================
|
||||
|
||||
def on_loot_event(self, item_name: str, value_ped: Decimal) -> None:
|
||||
"""Called when loot is received from LogWatcher.
|
||||
|
||||
Args:
|
||||
item_name: Name of the looted item
|
||||
value_ped: Value in PED (Decimal for precision)
|
||||
"""
|
||||
if not self.session_active:
|
||||
return
|
||||
|
||||
self._stats.loot_total += value_ped
|
||||
# Count actual loot as kills (exclude Shrapnel and Universal Ammo)
|
||||
if item_name not in ('Shrapnel', 'Universal Ammo'):
|
||||
self._stats.kills += 1
|
||||
|
||||
self._refresh_display()
|
||||
self.stats_updated.emit(self._stats.to_dict())
|
||||
|
||||
def on_damage_dealt(self, damage: float) -> None:
|
||||
"""Called when damage is dealt.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage dealt
|
||||
"""
|
||||
if not self.session_active:
|
||||
return
|
||||
|
||||
self._stats.damage_dealt += int(damage)
|
||||
self._refresh_display()
|
||||
self.stats_updated.emit(self._stats.to_dict())
|
||||
|
||||
def on_damage_taken(self, damage: float) -> None:
|
||||
"""Called when damage is taken.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage taken
|
||||
"""
|
||||
if not self.session_active:
|
||||
return
|
||||
|
||||
self._stats.damage_taken += int(damage)
|
||||
self._refresh_display()
|
||||
self.stats_updated.emit(self._stats.to_dict())
|
||||
|
||||
def on_global(self, value_ped: Decimal = Decimal('0.0')) -> None:
|
||||
"""Called on global event.
|
||||
|
||||
Args:
|
||||
value_ped: Value of the global in PED
|
||||
"""
|
||||
if not self.session_active:
|
||||
return
|
||||
|
||||
self._stats.globals_count += 1
|
||||
self._refresh_display()
|
||||
self.stats_updated.emit(self._stats.to_dict())
|
||||
|
||||
def on_hof(self, value_ped: Decimal = Decimal('0.0')) -> None:
|
||||
"""Called on Hall of Fame event.
|
||||
|
||||
Args:
|
||||
value_ped: Value of the HoF in PED
|
||||
"""
|
||||
if not self.session_active:
|
||||
return
|
||||
|
||||
self._stats.hofs_count += 1
|
||||
self._refresh_display()
|
||||
self.stats_updated.emit(self._stats.to_dict())
|
||||
|
||||
def update_display(self) -> None:
|
||||
"""Public method to refresh display (alias for _refresh_display)."""
|
||||
self._refresh_display()
|
||||
|
||||
def _update_session_time(self) -> None:
|
||||
"""Update the session time display."""
|
||||
if self._session_start:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class LoadoutConfig:
|
|||
weapon_ammo_pec: Decimal
|
||||
armor_name: str
|
||||
armor_decay_pec: Decimal
|
||||
heal_name: str
|
||||
heal_cost_pec: Decimal
|
||||
|
||||
# Optional fields for extended calculations
|
||||
|
|
@ -105,6 +106,10 @@ class LoadoutConfig:
|
|||
if 'shots_per_hour' in data:
|
||||
data['shots_per_hour'] = int(data['shots_per_hour'])
|
||||
|
||||
# Handle legacy configs without heal_name
|
||||
if 'heal_name' not in data:
|
||||
data['heal_name'] = '-- Custom --'
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
|
|
@ -233,6 +238,11 @@ class LoadoutManagerDialog(QDialog):
|
|||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
QLineEdit:disabled {
|
||||
background-color: #252525;
|
||||
color: #888888;
|
||||
border: 1px solid #2d2d2d;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid #4a90d9;
|
||||
}
|
||||
|
|
@ -308,7 +318,7 @@ class LoadoutManagerDialog(QDialog):
|
|||
# Weapon section
|
||||
self.weapon_group = DarkGroupBox("🔫 Weapon Configuration")
|
||||
self.weapon_combo = QComboBox()
|
||||
self.weapon_combo.setEditable(True)
|
||||
self.weapon_combo.setEditable(False) # Show dropdown list on click
|
||||
self.weapon_damage_edit = DecimalLineEdit()
|
||||
self.weapon_decay_edit = DecimalLineEdit()
|
||||
self.weapon_ammo_edit = DecimalLineEdit()
|
||||
|
|
@ -318,7 +328,7 @@ class LoadoutManagerDialog(QDialog):
|
|||
# Armor section
|
||||
self.armor_group = DarkGroupBox("🛡️ Armor Configuration")
|
||||
self.armor_combo = QComboBox()
|
||||
self.armor_combo.setEditable(True)
|
||||
self.armor_combo.setEditable(False) # Show dropdown list on click
|
||||
self.armor_decay_edit = DecimalLineEdit()
|
||||
|
||||
# Protection values
|
||||
|
|
@ -335,7 +345,7 @@ class LoadoutManagerDialog(QDialog):
|
|||
# Healing section
|
||||
self.heal_group = DarkGroupBox("💊 Healing Configuration")
|
||||
self.heal_combo = QComboBox()
|
||||
self.heal_combo.setEditable(True)
|
||||
self.heal_combo.setEditable(False) # Show dropdown list on click
|
||||
self.heal_cost_edit = DecimalLineEdit()
|
||||
|
||||
# Cost summary
|
||||
|
|
@ -527,36 +537,109 @@ class LoadoutManagerDialog(QDialog):
|
|||
self.heal_combo.addItem("-- Custom --")
|
||||
for heal in MOCK_HEALING:
|
||||
self.heal_combo.addItem(heal["name"])
|
||||
|
||||
# Set initial enabled state (all fields enabled for custom entry)
|
||||
self.weapon_damage_edit.setEnabled(True)
|
||||
self.weapon_decay_edit.setEnabled(True)
|
||||
self.weapon_ammo_edit.setEnabled(True)
|
||||
self.armor_decay_edit.setEnabled(True)
|
||||
self.protection_stab_edit.setEnabled(True)
|
||||
self.protection_cut_edit.setEnabled(True)
|
||||
self.protection_impact_edit.setEnabled(True)
|
||||
self.protection_pen_edit.setEnabled(True)
|
||||
self.protection_shrap_edit.setEnabled(True)
|
||||
self.protection_burn_edit.setEnabled(True)
|
||||
self.protection_cold_edit.setEnabled(True)
|
||||
self.protection_acid_edit.setEnabled(True)
|
||||
self.protection_elec_edit.setEnabled(True)
|
||||
self.heal_cost_edit.setEnabled(True)
|
||||
|
||||
def _on_weapon_changed(self, name: str):
|
||||
"""Handle weapon selection change."""
|
||||
for weapon in MOCK_WEAPONS:
|
||||
if weapon["name"] == name:
|
||||
self.weapon_damage_edit.set_decimal(weapon["damage"])
|
||||
self.weapon_decay_edit.set_decimal(weapon["decay"])
|
||||
self.weapon_ammo_edit.set_decimal(weapon["ammo"])
|
||||
break
|
||||
if name == "-- Custom --":
|
||||
# Enable manual entry for custom weapon
|
||||
self.weapon_damage_edit.setEnabled(True)
|
||||
self.weapon_decay_edit.setEnabled(True)
|
||||
self.weapon_ammo_edit.setEnabled(True)
|
||||
# Clear fields for user to enter custom values
|
||||
self.weapon_damage_edit.clear()
|
||||
self.weapon_decay_edit.clear()
|
||||
self.weapon_ammo_edit.clear()
|
||||
else:
|
||||
# Auto-fill stats for predefined weapon and disable fields
|
||||
for weapon in MOCK_WEAPONS:
|
||||
if weapon["name"] == name:
|
||||
self.weapon_damage_edit.set_decimal(weapon["damage"])
|
||||
self.weapon_decay_edit.set_decimal(weapon["decay"])
|
||||
self.weapon_ammo_edit.set_decimal(weapon["ammo"])
|
||||
break
|
||||
self.weapon_damage_edit.setEnabled(False)
|
||||
self.weapon_decay_edit.setEnabled(False)
|
||||
self.weapon_ammo_edit.setEnabled(False)
|
||||
self._update_calculations()
|
||||
|
||||
def _on_armor_changed(self, name: str):
|
||||
"""Handle armor selection change."""
|
||||
for armor in MOCK_ARMOR:
|
||||
if armor["name"] == name:
|
||||
self.armor_decay_edit.set_decimal(armor["decay"])
|
||||
self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0")))
|
||||
self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0")))
|
||||
self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0")))
|
||||
self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0")))
|
||||
self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0")))
|
||||
self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0")))
|
||||
break
|
||||
if name == "-- Custom --":
|
||||
# Enable manual entry for custom armor
|
||||
self.armor_decay_edit.setEnabled(True)
|
||||
self.protection_stab_edit.setEnabled(True)
|
||||
self.protection_cut_edit.setEnabled(True)
|
||||
self.protection_impact_edit.setEnabled(True)
|
||||
self.protection_pen_edit.setEnabled(True)
|
||||
self.protection_shrap_edit.setEnabled(True)
|
||||
self.protection_burn_edit.setEnabled(True)
|
||||
self.protection_cold_edit.setEnabled(True)
|
||||
self.protection_acid_edit.setEnabled(True)
|
||||
self.protection_elec_edit.setEnabled(True)
|
||||
# Clear fields for user to enter custom values
|
||||
self.armor_decay_edit.clear()
|
||||
self.protection_stab_edit.clear()
|
||||
self.protection_cut_edit.clear()
|
||||
self.protection_impact_edit.clear()
|
||||
self.protection_pen_edit.clear()
|
||||
self.protection_shrap_edit.clear()
|
||||
self.protection_burn_edit.clear()
|
||||
self.protection_cold_edit.clear()
|
||||
self.protection_acid_edit.clear()
|
||||
self.protection_elec_edit.clear()
|
||||
else:
|
||||
# Auto-fill stats for predefined armor and disable fields
|
||||
for armor in MOCK_ARMOR:
|
||||
if armor["name"] == name:
|
||||
self.armor_decay_edit.set_decimal(armor["decay"])
|
||||
self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0")))
|
||||
self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0")))
|
||||
self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0")))
|
||||
self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0")))
|
||||
self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0")))
|
||||
self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0")))
|
||||
break
|
||||
self.armor_decay_edit.setEnabled(False)
|
||||
self.protection_stab_edit.setEnabled(False)
|
||||
self.protection_cut_edit.setEnabled(False)
|
||||
self.protection_impact_edit.setEnabled(False)
|
||||
self.protection_pen_edit.setEnabled(False)
|
||||
self.protection_shrap_edit.setEnabled(False)
|
||||
self.protection_burn_edit.setEnabled(False)
|
||||
self.protection_cold_edit.setEnabled(False)
|
||||
self.protection_acid_edit.setEnabled(False)
|
||||
self.protection_elec_edit.setEnabled(False)
|
||||
|
||||
def _on_heal_changed(self, name: str):
|
||||
"""Handle healing selection change."""
|
||||
for heal in MOCK_HEALING:
|
||||
if heal["name"] == name:
|
||||
self.heal_cost_edit.set_decimal(heal["cost"])
|
||||
break
|
||||
if name == "-- Custom --":
|
||||
# Enable manual entry for custom healing
|
||||
self.heal_cost_edit.setEnabled(True)
|
||||
# Clear field for user to enter custom value
|
||||
self.heal_cost_edit.clear()
|
||||
else:
|
||||
# Auto-fill stats for predefined healing and disable field
|
||||
for heal in MOCK_HEALING:
|
||||
if heal["name"] == name:
|
||||
self.heal_cost_edit.set_decimal(heal["cost"])
|
||||
break
|
||||
self.heal_cost_edit.setEnabled(False)
|
||||
|
||||
def _update_calculations(self):
|
||||
"""Update DPP and cost calculations."""
|
||||
|
|
@ -601,6 +684,7 @@ class LoadoutManagerDialog(QDialog):
|
|||
weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(),
|
||||
armor_name=self.armor_combo.currentText(),
|
||||
armor_decay_pec=self.armor_decay_edit.get_decimal(),
|
||||
heal_name=self.heal_combo.currentText(),
|
||||
heal_cost_pec=self.heal_cost_edit.get_decimal(),
|
||||
shots_per_hour=self.shots_per_hour_spin.value(),
|
||||
protection_stab=self.protection_stab_edit.get_decimal(),
|
||||
|
|
@ -623,6 +707,11 @@ class LoadoutManagerDialog(QDialog):
|
|||
self.weapon_damage_edit.set_decimal(config.weapon_damage)
|
||||
self.weapon_decay_edit.set_decimal(config.weapon_decay_pec)
|
||||
self.weapon_ammo_edit.set_decimal(config.weapon_ammo_pec)
|
||||
# Enable/disable based on whether it's a custom weapon
|
||||
is_custom_weapon = config.weapon_name == "-- Custom --"
|
||||
self.weapon_damage_edit.setEnabled(is_custom_weapon)
|
||||
self.weapon_decay_edit.setEnabled(is_custom_weapon)
|
||||
self.weapon_ammo_edit.setEnabled(is_custom_weapon)
|
||||
|
||||
self.armor_combo.setCurrentText(config.armor_name)
|
||||
self.armor_decay_edit.set_decimal(config.armor_decay_pec)
|
||||
|
|
@ -636,9 +725,24 @@ class LoadoutManagerDialog(QDialog):
|
|||
self.protection_cold_edit.set_decimal(config.protection_cold)
|
||||
self.protection_acid_edit.set_decimal(config.protection_acid)
|
||||
self.protection_elec_edit.set_decimal(config.protection_electric)
|
||||
# Enable/disable based on whether it's custom armor
|
||||
is_custom_armor = config.armor_name == "-- Custom --"
|
||||
self.armor_decay_edit.setEnabled(is_custom_armor)
|
||||
self.protection_stab_edit.setEnabled(is_custom_armor)
|
||||
self.protection_cut_edit.setEnabled(is_custom_armor)
|
||||
self.protection_impact_edit.setEnabled(is_custom_armor)
|
||||
self.protection_pen_edit.setEnabled(is_custom_armor)
|
||||
self.protection_shrap_edit.setEnabled(is_custom_armor)
|
||||
self.protection_burn_edit.setEnabled(is_custom_armor)
|
||||
self.protection_cold_edit.setEnabled(is_custom_armor)
|
||||
self.protection_acid_edit.setEnabled(is_custom_armor)
|
||||
self.protection_elec_edit.setEnabled(is_custom_armor)
|
||||
|
||||
self.heal_combo.setCurrentText("-- Custom --")
|
||||
self.heal_combo.setCurrentText(config.heal_name if hasattr(config, 'heal_name') else "-- Custom --")
|
||||
self.heal_cost_edit.set_decimal(config.heal_cost_pec)
|
||||
# Enable/disable based on whether it's custom healing
|
||||
is_custom_heal = (config.heal_name if hasattr(config, 'heal_name') else "-- Custom --") == "-- Custom --"
|
||||
self.heal_cost_edit.setEnabled(is_custom_heal)
|
||||
|
||||
self._update_calculations()
|
||||
|
||||
|
|
@ -747,9 +851,42 @@ class LoadoutManagerDialog(QDialog):
|
|||
def _new_loadout(self):
|
||||
"""Clear all fields for a new loadout."""
|
||||
self.loadout_name_edit.clear()
|
||||
self.weapon_combo.setCurrentIndex(0)
|
||||
self.armor_combo.setCurrentIndex(0)
|
||||
self.heal_combo.setCurrentIndex(0)
|
||||
self.weapon_combo.setCurrentIndex(0) # "-- Custom --"
|
||||
self.armor_combo.setCurrentIndex(0) # "-- Custom --"
|
||||
self.heal_combo.setCurrentIndex(0) # "-- Custom --"
|
||||
|
||||
# Clear all fields
|
||||
self.weapon_damage_edit.clear()
|
||||
self.weapon_decay_edit.clear()
|
||||
self.weapon_ammo_edit.clear()
|
||||
self.armor_decay_edit.clear()
|
||||
self.protection_stab_edit.clear()
|
||||
self.protection_cut_edit.clear()
|
||||
self.protection_impact_edit.clear()
|
||||
self.protection_pen_edit.clear()
|
||||
self.protection_shrap_edit.clear()
|
||||
self.protection_burn_edit.clear()
|
||||
self.protection_cold_edit.clear()
|
||||
self.protection_acid_edit.clear()
|
||||
self.protection_elec_edit.clear()
|
||||
self.heal_cost_edit.clear()
|
||||
|
||||
# Enable all fields for custom entry (since "-- Custom --" is selected)
|
||||
self.weapon_damage_edit.setEnabled(True)
|
||||
self.weapon_decay_edit.setEnabled(True)
|
||||
self.weapon_ammo_edit.setEnabled(True)
|
||||
self.armor_decay_edit.setEnabled(True)
|
||||
self.protection_stab_edit.setEnabled(True)
|
||||
self.protection_cut_edit.setEnabled(True)
|
||||
self.protection_impact_edit.setEnabled(True)
|
||||
self.protection_pen_edit.setEnabled(True)
|
||||
self.protection_shrap_edit.setEnabled(True)
|
||||
self.protection_burn_edit.setEnabled(True)
|
||||
self.protection_cold_edit.setEnabled(True)
|
||||
self.protection_acid_edit.setEnabled(True)
|
||||
self.protection_elec_edit.setEnabled(True)
|
||||
self.heal_cost_edit.setEnabled(True)
|
||||
|
||||
self.mob_health_edit.set_decimal(Decimal("100"))
|
||||
self.current_loadout = None
|
||||
self._update_calculations()
|
||||
|
|
|
|||
Loading…
Reference in New Issue