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.database import DatabaseManager
|
||||||
from core.project_manager import ProjectManager, ProjectData, SessionData, LootEvent
|
from core.project_manager import ProjectManager, ProjectData, SessionData, LootEvent
|
||||||
from core.log_watcher import LogWatcher, MockLogGenerator
|
from core.log_watcher import LogWatcher, MockLogGenerator
|
||||||
|
from ui.hud_overlay import HUDOverlay
|
||||||
|
|
||||||
# Configure logging for user visibility
|
# Configure logging for user visibility
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -54,6 +55,7 @@ class LemontropiaApp:
|
||||||
self.pm = ProjectManager(self.db)
|
self.pm = ProjectManager(self.db)
|
||||||
self.watcher = None
|
self.watcher = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self.hud = None
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
logger.info("🍋 Initializing Lemontropia Suite...")
|
logger.info("🍋 Initializing Lemontropia Suite...")
|
||||||
|
|
@ -63,9 +65,30 @@ class LemontropiaApp:
|
||||||
|
|
||||||
logger.info("✅ Database ready")
|
logger.info("✅ Database ready")
|
||||||
|
|
||||||
|
# Initialize HUD overlay (Qt app must be created first)
|
||||||
|
self._init_hud()
|
||||||
|
|
||||||
# Ensure test data exists
|
# Ensure test data exists
|
||||||
self._ensure_mock_data()
|
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):
|
def _ensure_mock_data(self):
|
||||||
"""Create mock chat.log if it doesn't exist."""
|
"""Create mock chat.log if it doesn't exist."""
|
||||||
test_data_dir = Path(__file__).parent / "test-data"
|
test_data_dir = Path(__file__).parent / "test-data"
|
||||||
|
|
@ -193,6 +216,12 @@ class LemontropiaApp:
|
||||||
session = self.pm.start_session(project.id, notes=session_notes)
|
session = self.pm.start_session(project.id, notes=session_notes)
|
||||||
print(f"✅ SESSION started: ID {session.id}")
|
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
|
# Setup log watcher - use real log or mock based on .env
|
||||||
use_mock = os.getenv('USE_MOCK_DATA', 'true').lower() in ('true', '1', 'yes')
|
use_mock = os.getenv('USE_MOCK_DATA', 'true').lower() in ('true', '1', 'yes')
|
||||||
log_path = os.getenv('EU_CHAT_LOG_PATH', '')
|
log_path = os.getenv('EU_CHAT_LOG_PATH', '')
|
||||||
|
|
@ -213,13 +242,14 @@ class LemontropiaApp:
|
||||||
"""Handle log events."""
|
"""Handle log events."""
|
||||||
if event.event_type == 'loot':
|
if event.event_type == 'loot':
|
||||||
item_name = event.data.get('item_name', 'Unknown')
|
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
|
# Skip Universal Ammo - it's converted shrapnel, not loot
|
||||||
if item_name == 'Universal Ammo':
|
if item_name == 'Universal Ammo':
|
||||||
return
|
return
|
||||||
loot = LootEvent(
|
loot = LootEvent(
|
||||||
item_name=item_name,
|
item_name=item_name,
|
||||||
quantity=event.data.get('quantity', 1),
|
quantity=event.data.get('quantity', 1),
|
||||||
value_ped=event.data.get('value_ped', Decimal('0.0')),
|
value_ped=value_ped,
|
||||||
event_type='regular',
|
event_type='regular',
|
||||||
raw_log_line=event.raw_line
|
raw_log_line=event.raw_line
|
||||||
)
|
)
|
||||||
|
|
@ -227,18 +257,30 @@ class LemontropiaApp:
|
||||||
stats['loot'] += 1
|
stats['loot'] += 1
|
||||||
stats['total_ped'] += loot.value_ped
|
stats['total_ped'] += loot.value_ped
|
||||||
print(f" 💰 Loot: {loot.item_name} x{loot.quantity} ({loot.value_ped} 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':
|
elif event.event_type == 'global':
|
||||||
stats['globals'] += 1
|
stats['globals'] += 1
|
||||||
print(f" 🌍 GLOBAL: {event.data.get('player_name')} found {event.data.get('value_ped')} PED!")
|
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':
|
elif event.event_type == 'personal_global':
|
||||||
stats['personal_globals'] += 1
|
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!!! 🎉🎉🎉")
|
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':
|
elif event.event_type == 'hof':
|
||||||
stats['hofs'] += 1
|
stats['hofs'] += 1
|
||||||
print(f" 🏆 HALL OF FAME: {event.data.get('value_ped')} PED!")
|
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':
|
elif event.event_type == 'skill':
|
||||||
stats['skills'] += 1
|
stats['skills'] += 1
|
||||||
|
|
@ -257,16 +299,25 @@ class LemontropiaApp:
|
||||||
print(f" 💔 ENHANCER BROKEN: {event.data.get('enhancer_type')} on {event.data.get('weapon')}!")
|
print(f" 💔 ENHANCER BROKEN: {event.data.get('enhancer_type')} on {event.data.get('weapon')}!")
|
||||||
|
|
||||||
elif event.event_type == 'damage_dealt':
|
elif event.event_type == 'damage_dealt':
|
||||||
stats['damage_dealt'] += 1
|
damage = event.data.get('damage', 0)
|
||||||
print(f" 💥 Damage Dealt: {event.data.get('damage')} pts")
|
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':
|
elif event.event_type == 'critical_hit':
|
||||||
stats['damage_dealt'] += 1 # Count as damage dealt too
|
damage = event.data.get('damage', 0)
|
||||||
print(f" 💀 CRITICAL: {event.data.get('damage')} pts")
|
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':
|
elif event.event_type == 'damage_taken':
|
||||||
stats['damage_taken'] += 1
|
damage = event.data.get('damage', 0)
|
||||||
print(f" 🛡️ Damage Taken: {event.data.get('damage')} pts")
|
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':
|
elif event.event_type == 'evade':
|
||||||
stats['evades'] += 1
|
stats['evades'] += 1
|
||||||
|
|
@ -304,6 +355,10 @@ class LemontropiaApp:
|
||||||
|
|
||||||
await self.watcher.stop()
|
await self.watcher.stop()
|
||||||
|
|
||||||
|
# End HUD session if running
|
||||||
|
if self.hud:
|
||||||
|
self.hud.end_session()
|
||||||
|
|
||||||
# End session
|
# End session
|
||||||
self.pm.end_session(session.id)
|
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 dataclasses import dataclass, asdict
|
||||||
from typing import Optional, Dict, Any
|
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 (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QLabel, QFrame, QSizePolicy
|
QLabel, QFrame, QSizePolicy
|
||||||
|
|
@ -112,6 +117,7 @@ class HUDOverlay(QWidget):
|
||||||
# Session tracking
|
# Session tracking
|
||||||
self._session_start: Optional[datetime] = None
|
self._session_start: Optional[datetime] = None
|
||||||
self._stats = HUDStats()
|
self._stats = HUDStats()
|
||||||
|
self.session_active = False # Public flag for session state
|
||||||
|
|
||||||
# Drag state
|
# Drag state
|
||||||
self._dragging = False
|
self._dragging = False
|
||||||
|
|
@ -387,11 +393,12 @@ class HUDOverlay(QWidget):
|
||||||
self._dragging = True
|
self._dragging = True
|
||||||
self._drag_offset = event.pos()
|
self._drag_offset = event.pos()
|
||||||
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||||||
|
self._enable_click_through(False) # Disable click-through for dragging
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
# Pass through to underlying window
|
# Enable click-through and pass to underlying window
|
||||||
event.ignore()
|
|
||||||
self._enable_click_through(True)
|
self._enable_click_through(True)
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||||||
"""Handle mouse move - drag window if in drag mode."""
|
"""Handle mouse move - drag window if in drag mode."""
|
||||||
|
|
@ -412,6 +419,8 @@ class HUDOverlay(QWidget):
|
||||||
self._dragging = False
|
self._dragging = False
|
||||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||||
self._save_position()
|
self._save_position()
|
||||||
|
# Re-enable click-through after drag
|
||||||
|
self._enable_click_through(True)
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
|
@ -427,11 +436,46 @@ class HUDOverlay(QWidget):
|
||||||
|
|
||||||
When enabled, mouse events pass through to the window below.
|
When enabled, mouse events pass through to the window below.
|
||||||
When disabled (Ctrl held), window captures mouse events for dragging.
|
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:
|
if sys.platform == 'win32':
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
self._set_click_through_win32(enable)
|
||||||
else:
|
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:
|
def keyPressEvent(self, event) -> None:
|
||||||
"""Handle key press - detect Ctrl for drag mode."""
|
"""Handle key press - detect Ctrl for drag mode."""
|
||||||
|
|
@ -455,10 +499,18 @@ class HUDOverlay(QWidget):
|
||||||
# SESSION MANAGEMENT
|
# SESSION MANAGEMENT
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
def start_session(self) -> None:
|
def start_session(self, weapon: str = "Unknown", loadout: str = "Default") -> None:
|
||||||
"""Start a new hunting/mining/crafting session."""
|
"""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._session_start = datetime.now()
|
||||||
self._stats = HUDStats() # Reset stats
|
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._timer.start(1000) # Update every second
|
||||||
self._refresh_display()
|
self._refresh_display()
|
||||||
self.status_label.setText("● Live - Recording")
|
self.status_label.setText("● Live - Recording")
|
||||||
|
|
@ -468,10 +520,89 @@ class HUDOverlay(QWidget):
|
||||||
"""End the current session."""
|
"""End the current session."""
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
self._session_start = None
|
self._session_start = None
|
||||||
|
self.session_active = False
|
||||||
self._save_position() # Save final stats
|
self._save_position() # Save final stats
|
||||||
self.status_label.setText("○ Paused")
|
self.status_label.setText("○ Paused")
|
||||||
self.status_label.setStyleSheet("font-size: 9px; color: #888888;")
|
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:
|
def _update_session_time(self) -> None:
|
||||||
"""Update the session time display."""
|
"""Update the session time display."""
|
||||||
if self._session_start:
|
if self._session_start:
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class LoadoutConfig:
|
||||||
weapon_ammo_pec: Decimal
|
weapon_ammo_pec: Decimal
|
||||||
armor_name: str
|
armor_name: str
|
||||||
armor_decay_pec: Decimal
|
armor_decay_pec: Decimal
|
||||||
|
heal_name: str
|
||||||
heal_cost_pec: Decimal
|
heal_cost_pec: Decimal
|
||||||
|
|
||||||
# Optional fields for extended calculations
|
# Optional fields for extended calculations
|
||||||
|
|
@ -105,6 +106,10 @@ class LoadoutConfig:
|
||||||
if 'shots_per_hour' in data:
|
if 'shots_per_hour' in data:
|
||||||
data['shots_per_hour'] = int(data['shots_per_hour'])
|
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)
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -233,6 +238,11 @@ class LoadoutManagerDialog(QDialog):
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
QLineEdit:disabled {
|
||||||
|
background-color: #252525;
|
||||||
|
color: #888888;
|
||||||
|
border: 1px solid #2d2d2d;
|
||||||
|
}
|
||||||
QLineEdit:focus {
|
QLineEdit:focus {
|
||||||
border: 1px solid #4a90d9;
|
border: 1px solid #4a90d9;
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +318,7 @@ class LoadoutManagerDialog(QDialog):
|
||||||
# Weapon section
|
# Weapon section
|
||||||
self.weapon_group = DarkGroupBox("🔫 Weapon Configuration")
|
self.weapon_group = DarkGroupBox("🔫 Weapon Configuration")
|
||||||
self.weapon_combo = QComboBox()
|
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_damage_edit = DecimalLineEdit()
|
||||||
self.weapon_decay_edit = DecimalLineEdit()
|
self.weapon_decay_edit = DecimalLineEdit()
|
||||||
self.weapon_ammo_edit = DecimalLineEdit()
|
self.weapon_ammo_edit = DecimalLineEdit()
|
||||||
|
|
@ -318,7 +328,7 @@ class LoadoutManagerDialog(QDialog):
|
||||||
# Armor section
|
# Armor section
|
||||||
self.armor_group = DarkGroupBox("🛡️ Armor Configuration")
|
self.armor_group = DarkGroupBox("🛡️ Armor Configuration")
|
||||||
self.armor_combo = QComboBox()
|
self.armor_combo = QComboBox()
|
||||||
self.armor_combo.setEditable(True)
|
self.armor_combo.setEditable(False) # Show dropdown list on click
|
||||||
self.armor_decay_edit = DecimalLineEdit()
|
self.armor_decay_edit = DecimalLineEdit()
|
||||||
|
|
||||||
# Protection values
|
# Protection values
|
||||||
|
|
@ -335,7 +345,7 @@ class LoadoutManagerDialog(QDialog):
|
||||||
# Healing section
|
# Healing section
|
||||||
self.heal_group = DarkGroupBox("💊 Healing Configuration")
|
self.heal_group = DarkGroupBox("💊 Healing Configuration")
|
||||||
self.heal_combo = QComboBox()
|
self.heal_combo = QComboBox()
|
||||||
self.heal_combo.setEditable(True)
|
self.heal_combo.setEditable(False) # Show dropdown list on click
|
||||||
self.heal_cost_edit = DecimalLineEdit()
|
self.heal_cost_edit = DecimalLineEdit()
|
||||||
|
|
||||||
# Cost summary
|
# Cost summary
|
||||||
|
|
@ -528,35 +538,108 @@ class LoadoutManagerDialog(QDialog):
|
||||||
for heal in MOCK_HEALING:
|
for heal in MOCK_HEALING:
|
||||||
self.heal_combo.addItem(heal["name"])
|
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):
|
def _on_weapon_changed(self, name: str):
|
||||||
"""Handle weapon selection change."""
|
"""Handle weapon selection change."""
|
||||||
for weapon in MOCK_WEAPONS:
|
if name == "-- Custom --":
|
||||||
if weapon["name"] == name:
|
# Enable manual entry for custom weapon
|
||||||
self.weapon_damage_edit.set_decimal(weapon["damage"])
|
self.weapon_damage_edit.setEnabled(True)
|
||||||
self.weapon_decay_edit.set_decimal(weapon["decay"])
|
self.weapon_decay_edit.setEnabled(True)
|
||||||
self.weapon_ammo_edit.set_decimal(weapon["ammo"])
|
self.weapon_ammo_edit.setEnabled(True)
|
||||||
break
|
# 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()
|
self._update_calculations()
|
||||||
|
|
||||||
def _on_armor_changed(self, name: str):
|
def _on_armor_changed(self, name: str):
|
||||||
"""Handle armor selection change."""
|
"""Handle armor selection change."""
|
||||||
for armor in MOCK_ARMOR:
|
if name == "-- Custom --":
|
||||||
if armor["name"] == name:
|
# Enable manual entry for custom armor
|
||||||
self.armor_decay_edit.set_decimal(armor["decay"])
|
self.armor_decay_edit.setEnabled(True)
|
||||||
self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0")))
|
self.protection_stab_edit.setEnabled(True)
|
||||||
self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0")))
|
self.protection_cut_edit.setEnabled(True)
|
||||||
self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0")))
|
self.protection_impact_edit.setEnabled(True)
|
||||||
self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0")))
|
self.protection_pen_edit.setEnabled(True)
|
||||||
self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0")))
|
self.protection_shrap_edit.setEnabled(True)
|
||||||
self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0")))
|
self.protection_burn_edit.setEnabled(True)
|
||||||
break
|
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):
|
def _on_heal_changed(self, name: str):
|
||||||
"""Handle healing selection change."""
|
"""Handle healing selection change."""
|
||||||
for heal in MOCK_HEALING:
|
if name == "-- Custom --":
|
||||||
if heal["name"] == name:
|
# Enable manual entry for custom healing
|
||||||
self.heal_cost_edit.set_decimal(heal["cost"])
|
self.heal_cost_edit.setEnabled(True)
|
||||||
break
|
# 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):
|
def _update_calculations(self):
|
||||||
"""Update DPP and cost calculations."""
|
"""Update DPP and cost calculations."""
|
||||||
|
|
@ -601,6 +684,7 @@ class LoadoutManagerDialog(QDialog):
|
||||||
weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(),
|
weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(),
|
||||||
armor_name=self.armor_combo.currentText(),
|
armor_name=self.armor_combo.currentText(),
|
||||||
armor_decay_pec=self.armor_decay_edit.get_decimal(),
|
armor_decay_pec=self.armor_decay_edit.get_decimal(),
|
||||||
|
heal_name=self.heal_combo.currentText(),
|
||||||
heal_cost_pec=self.heal_cost_edit.get_decimal(),
|
heal_cost_pec=self.heal_cost_edit.get_decimal(),
|
||||||
shots_per_hour=self.shots_per_hour_spin.value(),
|
shots_per_hour=self.shots_per_hour_spin.value(),
|
||||||
protection_stab=self.protection_stab_edit.get_decimal(),
|
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_damage_edit.set_decimal(config.weapon_damage)
|
||||||
self.weapon_decay_edit.set_decimal(config.weapon_decay_pec)
|
self.weapon_decay_edit.set_decimal(config.weapon_decay_pec)
|
||||||
self.weapon_ammo_edit.set_decimal(config.weapon_ammo_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_combo.setCurrentText(config.armor_name)
|
||||||
self.armor_decay_edit.set_decimal(config.armor_decay_pec)
|
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_cold_edit.set_decimal(config.protection_cold)
|
||||||
self.protection_acid_edit.set_decimal(config.protection_acid)
|
self.protection_acid_edit.set_decimal(config.protection_acid)
|
||||||
self.protection_elec_edit.set_decimal(config.protection_electric)
|
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)
|
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()
|
self._update_calculations()
|
||||||
|
|
||||||
|
|
@ -747,9 +851,42 @@ class LoadoutManagerDialog(QDialog):
|
||||||
def _new_loadout(self):
|
def _new_loadout(self):
|
||||||
"""Clear all fields for a new loadout."""
|
"""Clear all fields for a new loadout."""
|
||||||
self.loadout_name_edit.clear()
|
self.loadout_name_edit.clear()
|
||||||
self.weapon_combo.setCurrentIndex(0)
|
self.weapon_combo.setCurrentIndex(0) # "-- Custom --"
|
||||||
self.armor_combo.setCurrentIndex(0)
|
self.armor_combo.setCurrentIndex(0) # "-- Custom --"
|
||||||
self.heal_combo.setCurrentIndex(0)
|
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.mob_health_edit.set_decimal(Decimal("100"))
|
||||||
self.current_loadout = None
|
self.current_loadout = None
|
||||||
self._update_calculations()
|
self._update_calculations()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue