diff --git a/core/nexus_api.py b/core/nexus_api.py new file mode 100644 index 0000000..7e59a4b --- /dev/null +++ b/core/nexus_api.py @@ -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', +] diff --git a/docs/WikiJS-Setup-Guide.md b/docs/WikiJS-Setup-Guide.md new file mode 100644 index 0000000..83f783b --- /dev/null +++ b/docs/WikiJS-Setup-Guide.md @@ -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!** 🍋📚 diff --git a/docs/portainer-wikijs-stack.yml b/docs/portainer-wikijs-stack.yml new file mode 100644 index 0000000..1cb5068 --- /dev/null +++ b/docs/portainer-wikijs-stack.yml @@ -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: diff --git a/docs/wikijs-add-to-existing-stack.yml b/docs/wikijs-add-to-existing-stack.yml new file mode 100644 index 0000000..8f51cc9 --- /dev/null +++ b/docs/wikijs-add-to-existing-stack.yml @@ -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 diff --git a/main.py b/main.py index ee6cf43..c9e351a 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,7 @@ sys.path.insert(0, str(core_dir)) from core.database import DatabaseManager from core.project_manager import ProjectManager, ProjectData, SessionData, LootEvent from core.log_watcher import LogWatcher, MockLogGenerator +from ui.hud_overlay import HUDOverlay # Configure logging for user visibility logging.basicConfig( @@ -54,6 +55,7 @@ class LemontropiaApp: self.pm = ProjectManager(self.db) self.watcher = None self._running = False + self.hud = None # Initialize database logger.info("🍋 Initializing Lemontropia Suite...") @@ -63,9 +65,30 @@ class LemontropiaApp: logger.info("✅ Database ready") + # Initialize HUD overlay (Qt app must be created first) + self._init_hud() + # Ensure test data exists self._ensure_mock_data() + def _init_hud(self): + """Initialize the HUD overlay.""" + try: + from PyQt6.QtWidgets import QApplication + + # Create QApplication if not exists + self.qt_app = QApplication.instance() + if self.qt_app is None: + self.qt_app = QApplication(sys.argv) + self.qt_app.setQuitOnLastWindowClosed(False) + + # Create HUD + self.hud = HUDOverlay() + logger.info("✅ HUD overlay ready") + except Exception as e: + logger.warning(f"⚠️ HUD initialization failed: {e}") + self.hud = None + def _ensure_mock_data(self): """Create mock chat.log if it doesn't exist.""" test_data_dir = Path(__file__).parent / "test-data" @@ -193,6 +216,12 @@ class LemontropiaApp: session = self.pm.start_session(project.id, notes=session_notes) print(f"✅ SESSION started: ID {session.id}") + # Show HUD if available + if self.hud: + self.hud.show() + self.hud.start_session(weapon="Unknown", loadout="Default") + logger.info("✅ HUD displayed") + # Setup log watcher - use real log or mock based on .env use_mock = os.getenv('USE_MOCK_DATA', 'true').lower() in ('true', '1', 'yes') log_path = os.getenv('EU_CHAT_LOG_PATH', '') @@ -213,13 +242,14 @@ class LemontropiaApp: """Handle log events.""" if event.event_type == 'loot': item_name = event.data.get('item_name', 'Unknown') + value_ped = event.data.get('value_ped', Decimal('0.0')) # Skip Universal Ammo - it's converted shrapnel, not loot if item_name == 'Universal Ammo': return loot = LootEvent( item_name=item_name, quantity=event.data.get('quantity', 1), - value_ped=event.data.get('value_ped', Decimal('0.0')), + value_ped=value_ped, event_type='regular', raw_log_line=event.raw_line ) @@ -227,18 +257,30 @@ class LemontropiaApp: stats['loot'] += 1 stats['total_ped'] += loot.value_ped print(f" 💰 Loot: {loot.item_name} x{loot.quantity} ({loot.value_ped} PED)") + # Update HUD + if self.hud: + self.hud.on_loot_event(item_name, value_ped) elif event.event_type == 'global': stats['globals'] += 1 print(f" 🌍 GLOBAL: {event.data.get('player_name')} found {event.data.get('value_ped')} PED!") + if self.hud: + value_ped = event.data.get('value_ped', Decimal('0.0')) + self.hud.on_global(value_ped) elif event.event_type == 'personal_global': stats['personal_globals'] += 1 print(f" 🎉🎉🎉 YOUR GLOBAL: {event.data.get('player_name')} killed {event.data.get('creature')} for {event.data.get('value_ped')} PED!!! 🎉🎉🎉") + if self.hud: + value_ped = event.data.get('value_ped', Decimal('0.0')) + self.hud.on_global(value_ped) elif event.event_type == 'hof': stats['hofs'] += 1 print(f" 🏆 HALL OF FAME: {event.data.get('value_ped')} PED!") + if self.hud: + value_ped = event.data.get('value_ped', Decimal('0.0')) + self.hud.on_hof(value_ped) elif event.event_type == 'skill': stats['skills'] += 1 @@ -257,16 +299,25 @@ class LemontropiaApp: print(f" 💔 ENHANCER BROKEN: {event.data.get('enhancer_type')} on {event.data.get('weapon')}!") elif event.event_type == 'damage_dealt': - stats['damage_dealt'] += 1 - print(f" 💥 Damage Dealt: {event.data.get('damage')} pts") + damage = event.data.get('damage', 0) + stats['damage_dealt'] += damage + print(f" 💥 Damage Dealt: {damage} pts") + if self.hud: + self.hud.on_damage_dealt(float(damage)) elif event.event_type == 'critical_hit': - stats['damage_dealt'] += 1 # Count as damage dealt too - print(f" 💀 CRITICAL: {event.data.get('damage')} pts") + damage = event.data.get('damage', 0) + stats['damage_dealt'] += damage # Count as damage dealt too + print(f" 💀 CRITICAL: {damage} pts") + if self.hud: + self.hud.on_damage_dealt(float(damage)) elif event.event_type == 'damage_taken': - stats['damage_taken'] += 1 - print(f" 🛡️ Damage Taken: {event.data.get('damage')} pts") + damage = event.data.get('damage', 0) + stats['damage_taken'] += damage + print(f" 🛡️ Damage Taken: {damage} pts") + if self.hud: + self.hud.on_damage_taken(float(damage)) elif event.event_type == 'evade': stats['evades'] += 1 @@ -304,6 +355,10 @@ class LemontropiaApp: await self.watcher.stop() + # End HUD session if running + if self.hud: + self.hud.end_session() + # End session self.pm.end_session(session.id) diff --git a/tests/test_nexus_api.py b/tests/test_nexus_api.py new file mode 100644 index 0000000..8af184a --- /dev/null +++ b/tests/test_nexus_api.py @@ -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"]) diff --git a/ui/hud_overlay.py b/ui/hud_overlay.py index 124620c..0e83010 100644 --- a/ui/hud_overlay.py +++ b/ui/hud_overlay.py @@ -10,6 +10,11 @@ from datetime import datetime, timedelta from dataclasses import dataclass, asdict from typing import Optional, Dict, Any +# Windows-specific imports for click-through support +if sys.platform == 'win32': + import ctypes + from ctypes import wintypes + from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QSizePolicy @@ -112,6 +117,7 @@ class HUDOverlay(QWidget): # Session tracking self._session_start: Optional[datetime] = None self._stats = HUDStats() + self.session_active = False # Public flag for session state # Drag state self._dragging = False @@ -387,11 +393,12 @@ class HUDOverlay(QWidget): self._dragging = True self._drag_offset = event.pos() self.setCursor(Qt.CursorShape.ClosedHandCursor) + self._enable_click_through(False) # Disable click-through for dragging event.accept() else: - # Pass through to underlying window - event.ignore() + # Enable click-through and pass to underlying window self._enable_click_through(True) + event.ignore() def mouseMoveEvent(self, event: QMouseEvent) -> None: """Handle mouse move - drag window if in drag mode.""" @@ -412,6 +419,8 @@ class HUDOverlay(QWidget): self._dragging = False self.setCursor(Qt.CursorShape.ArrowCursor) self._save_position() + # Re-enable click-through after drag + self._enable_click_through(True) event.accept() else: event.ignore() @@ -427,11 +436,46 @@ class HUDOverlay(QWidget): When enabled, mouse events pass through to the window below. When disabled (Ctrl held), window captures mouse events for dragging. + + On Windows: Uses WinAPI for proper click-through support. + On other platforms: Uses Qt's WA_TransparentForMouseEvents. """ - if enable: - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + if sys.platform == 'win32': + self._set_click_through_win32(enable) else: - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + # Use Qt's built-in for non-Windows platforms + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enable) + + def _set_click_through_win32(self, enabled: bool) -> None: + """ + Enable/disable click-through on Windows using WinAPI. + + Uses SetWindowLongW to modify the window's extended style flags: + - WS_EX_TRANSPARENT (0x00000020): Allows mouse events to pass through + - WS_EX_LAYERED (0x00080000): Required for transparency effects + + Args: + enabled: True to enable click-through, False to capture mouse events + """ + GWL_EXSTYLE = -20 + WS_EX_TRANSPARENT = 0x00000020 + WS_EX_LAYERED = 0x00080000 + + try: + hwnd = self.winId().__int__() + + # Get current extended style + style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) + + if enabled: + style |= WS_EX_TRANSPARENT | WS_EX_LAYERED + else: + style &= ~(WS_EX_TRANSPARENT | WS_EX_LAYERED) + + ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style) + except Exception: + # Fallback to Qt method if WinAPI fails + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enabled) def keyPressEvent(self, event) -> None: """Handle key press - detect Ctrl for drag mode.""" @@ -455,10 +499,18 @@ class HUDOverlay(QWidget): # SESSION MANAGEMENT # ======================================================================== - def start_session(self) -> None: - """Start a new hunting/mining/crafting session.""" + def start_session(self, weapon: str = "Unknown", loadout: str = "Default") -> None: + """Start a new hunting/mining/crafting session. + + Args: + weapon: Name of the current weapon + loadout: Name of the current loadout + """ self._session_start = datetime.now() self._stats = HUDStats() # Reset stats + self._stats.current_weapon = weapon + self._stats.current_loadout = loadout + self.session_active = True self._timer.start(1000) # Update every second self._refresh_display() self.status_label.setText("● Live - Recording") @@ -468,10 +520,89 @@ class HUDOverlay(QWidget): """End the current session.""" self._timer.stop() self._session_start = None + self.session_active = False self._save_position() # Save final stats self.status_label.setText("○ Paused") self.status_label.setStyleSheet("font-size: 9px; color: #888888;") + # ======================================================================== + # EVENT HANDLERS (Called from LogWatcher) + # ======================================================================== + + def on_loot_event(self, item_name: str, value_ped: Decimal) -> None: + """Called when loot is received from LogWatcher. + + Args: + item_name: Name of the looted item + value_ped: Value in PED (Decimal for precision) + """ + if not self.session_active: + return + + self._stats.loot_total += value_ped + # Count actual loot as kills (exclude Shrapnel and Universal Ammo) + if item_name not in ('Shrapnel', 'Universal Ammo'): + self._stats.kills += 1 + + self._refresh_display() + self.stats_updated.emit(self._stats.to_dict()) + + def on_damage_dealt(self, damage: float) -> None: + """Called when damage is dealt. + + Args: + damage: Amount of damage dealt + """ + if not self.session_active: + return + + self._stats.damage_dealt += int(damage) + self._refresh_display() + self.stats_updated.emit(self._stats.to_dict()) + + def on_damage_taken(self, damage: float) -> None: + """Called when damage is taken. + + Args: + damage: Amount of damage taken + """ + if not self.session_active: + return + + self._stats.damage_taken += int(damage) + self._refresh_display() + self.stats_updated.emit(self._stats.to_dict()) + + def on_global(self, value_ped: Decimal = Decimal('0.0')) -> None: + """Called on global event. + + Args: + value_ped: Value of the global in PED + """ + if not self.session_active: + return + + self._stats.globals_count += 1 + self._refresh_display() + self.stats_updated.emit(self._stats.to_dict()) + + def on_hof(self, value_ped: Decimal = Decimal('0.0')) -> None: + """Called on Hall of Fame event. + + Args: + value_ped: Value of the HoF in PED + """ + if not self.session_active: + return + + self._stats.hofs_count += 1 + self._refresh_display() + self.stats_updated.emit(self._stats.to_dict()) + + def update_display(self) -> None: + """Public method to refresh display (alias for _refresh_display).""" + self._refresh_display() + def _update_session_time(self) -> None: """Update the session time display.""" if self._session_start: diff --git a/ui/loadout_manager.py b/ui/loadout_manager.py index 265e44f..1486247 100644 --- a/ui/loadout_manager.py +++ b/ui/loadout_manager.py @@ -35,6 +35,7 @@ class LoadoutConfig: weapon_ammo_pec: Decimal armor_name: str armor_decay_pec: Decimal + heal_name: str heal_cost_pec: Decimal # Optional fields for extended calculations @@ -105,6 +106,10 @@ class LoadoutConfig: if 'shots_per_hour' in data: data['shots_per_hour'] = int(data['shots_per_hour']) + # Handle legacy configs without heal_name + if 'heal_name' not in data: + data['heal_name'] = '-- Custom --' + return cls(**data) @@ -233,6 +238,11 @@ class LoadoutManagerDialog(QDialog): border-radius: 4px; padding: 5px; } + QLineEdit:disabled { + background-color: #252525; + color: #888888; + border: 1px solid #2d2d2d; + } QLineEdit:focus { border: 1px solid #4a90d9; } @@ -308,7 +318,7 @@ class LoadoutManagerDialog(QDialog): # Weapon section self.weapon_group = DarkGroupBox("🔫 Weapon Configuration") self.weapon_combo = QComboBox() - self.weapon_combo.setEditable(True) + self.weapon_combo.setEditable(False) # Show dropdown list on click self.weapon_damage_edit = DecimalLineEdit() self.weapon_decay_edit = DecimalLineEdit() self.weapon_ammo_edit = DecimalLineEdit() @@ -318,7 +328,7 @@ class LoadoutManagerDialog(QDialog): # Armor section self.armor_group = DarkGroupBox("🛡️ Armor Configuration") self.armor_combo = QComboBox() - self.armor_combo.setEditable(True) + self.armor_combo.setEditable(False) # Show dropdown list on click self.armor_decay_edit = DecimalLineEdit() # Protection values @@ -335,7 +345,7 @@ class LoadoutManagerDialog(QDialog): # Healing section self.heal_group = DarkGroupBox("💊 Healing Configuration") self.heal_combo = QComboBox() - self.heal_combo.setEditable(True) + self.heal_combo.setEditable(False) # Show dropdown list on click self.heal_cost_edit = DecimalLineEdit() # Cost summary @@ -527,36 +537,109 @@ class LoadoutManagerDialog(QDialog): self.heal_combo.addItem("-- Custom --") for heal in MOCK_HEALING: self.heal_combo.addItem(heal["name"]) + + # Set initial enabled state (all fields enabled for custom entry) + self.weapon_damage_edit.setEnabled(True) + self.weapon_decay_edit.setEnabled(True) + self.weapon_ammo_edit.setEnabled(True) + self.armor_decay_edit.setEnabled(True) + self.protection_stab_edit.setEnabled(True) + self.protection_cut_edit.setEnabled(True) + self.protection_impact_edit.setEnabled(True) + self.protection_pen_edit.setEnabled(True) + self.protection_shrap_edit.setEnabled(True) + self.protection_burn_edit.setEnabled(True) + self.protection_cold_edit.setEnabled(True) + self.protection_acid_edit.setEnabled(True) + self.protection_elec_edit.setEnabled(True) + self.heal_cost_edit.setEnabled(True) def _on_weapon_changed(self, name: str): """Handle weapon selection change.""" - for weapon in MOCK_WEAPONS: - if weapon["name"] == name: - self.weapon_damage_edit.set_decimal(weapon["damage"]) - self.weapon_decay_edit.set_decimal(weapon["decay"]) - self.weapon_ammo_edit.set_decimal(weapon["ammo"]) - break + if name == "-- Custom --": + # Enable manual entry for custom weapon + self.weapon_damage_edit.setEnabled(True) + self.weapon_decay_edit.setEnabled(True) + self.weapon_ammo_edit.setEnabled(True) + # Clear fields for user to enter custom values + self.weapon_damage_edit.clear() + self.weapon_decay_edit.clear() + self.weapon_ammo_edit.clear() + else: + # Auto-fill stats for predefined weapon and disable fields + for weapon in MOCK_WEAPONS: + if weapon["name"] == name: + self.weapon_damage_edit.set_decimal(weapon["damage"]) + self.weapon_decay_edit.set_decimal(weapon["decay"]) + self.weapon_ammo_edit.set_decimal(weapon["ammo"]) + break + self.weapon_damage_edit.setEnabled(False) + self.weapon_decay_edit.setEnabled(False) + self.weapon_ammo_edit.setEnabled(False) self._update_calculations() def _on_armor_changed(self, name: str): """Handle armor selection change.""" - for armor in MOCK_ARMOR: - if armor["name"] == name: - self.armor_decay_edit.set_decimal(armor["decay"]) - self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0"))) - self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0"))) - self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0"))) - self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0"))) - self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0"))) - self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0"))) - break + if name == "-- Custom --": + # Enable manual entry for custom armor + self.armor_decay_edit.setEnabled(True) + self.protection_stab_edit.setEnabled(True) + self.protection_cut_edit.setEnabled(True) + self.protection_impact_edit.setEnabled(True) + self.protection_pen_edit.setEnabled(True) + self.protection_shrap_edit.setEnabled(True) + self.protection_burn_edit.setEnabled(True) + self.protection_cold_edit.setEnabled(True) + self.protection_acid_edit.setEnabled(True) + self.protection_elec_edit.setEnabled(True) + # Clear fields for user to enter custom values + self.armor_decay_edit.clear() + self.protection_stab_edit.clear() + self.protection_cut_edit.clear() + self.protection_impact_edit.clear() + self.protection_pen_edit.clear() + self.protection_shrap_edit.clear() + self.protection_burn_edit.clear() + self.protection_cold_edit.clear() + self.protection_acid_edit.clear() + self.protection_elec_edit.clear() + else: + # Auto-fill stats for predefined armor and disable fields + for armor in MOCK_ARMOR: + if armor["name"] == name: + self.armor_decay_edit.set_decimal(armor["decay"]) + self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0"))) + self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0"))) + self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0"))) + self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0"))) + self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0"))) + self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0"))) + break + self.armor_decay_edit.setEnabled(False) + self.protection_stab_edit.setEnabled(False) + self.protection_cut_edit.setEnabled(False) + self.protection_impact_edit.setEnabled(False) + self.protection_pen_edit.setEnabled(False) + self.protection_shrap_edit.setEnabled(False) + self.protection_burn_edit.setEnabled(False) + self.protection_cold_edit.setEnabled(False) + self.protection_acid_edit.setEnabled(False) + self.protection_elec_edit.setEnabled(False) def _on_heal_changed(self, name: str): """Handle healing selection change.""" - for heal in MOCK_HEALING: - if heal["name"] == name: - self.heal_cost_edit.set_decimal(heal["cost"]) - break + if name == "-- Custom --": + # Enable manual entry for custom healing + self.heal_cost_edit.setEnabled(True) + # Clear field for user to enter custom value + self.heal_cost_edit.clear() + else: + # Auto-fill stats for predefined healing and disable field + for heal in MOCK_HEALING: + if heal["name"] == name: + self.heal_cost_edit.set_decimal(heal["cost"]) + break + self.heal_cost_edit.setEnabled(False) def _update_calculations(self): """Update DPP and cost calculations.""" @@ -601,6 +684,7 @@ class LoadoutManagerDialog(QDialog): weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(), armor_name=self.armor_combo.currentText(), armor_decay_pec=self.armor_decay_edit.get_decimal(), + heal_name=self.heal_combo.currentText(), heal_cost_pec=self.heal_cost_edit.get_decimal(), shots_per_hour=self.shots_per_hour_spin.value(), protection_stab=self.protection_stab_edit.get_decimal(), @@ -623,6 +707,11 @@ class LoadoutManagerDialog(QDialog): self.weapon_damage_edit.set_decimal(config.weapon_damage) self.weapon_decay_edit.set_decimal(config.weapon_decay_pec) self.weapon_ammo_edit.set_decimal(config.weapon_ammo_pec) + # Enable/disable based on whether it's a custom weapon + is_custom_weapon = config.weapon_name == "-- Custom --" + self.weapon_damage_edit.setEnabled(is_custom_weapon) + self.weapon_decay_edit.setEnabled(is_custom_weapon) + self.weapon_ammo_edit.setEnabled(is_custom_weapon) self.armor_combo.setCurrentText(config.armor_name) self.armor_decay_edit.set_decimal(config.armor_decay_pec) @@ -636,9 +725,24 @@ class LoadoutManagerDialog(QDialog): self.protection_cold_edit.set_decimal(config.protection_cold) self.protection_acid_edit.set_decimal(config.protection_acid) self.protection_elec_edit.set_decimal(config.protection_electric) + # Enable/disable based on whether it's custom armor + is_custom_armor = config.armor_name == "-- Custom --" + self.armor_decay_edit.setEnabled(is_custom_armor) + self.protection_stab_edit.setEnabled(is_custom_armor) + self.protection_cut_edit.setEnabled(is_custom_armor) + self.protection_impact_edit.setEnabled(is_custom_armor) + self.protection_pen_edit.setEnabled(is_custom_armor) + self.protection_shrap_edit.setEnabled(is_custom_armor) + self.protection_burn_edit.setEnabled(is_custom_armor) + self.protection_cold_edit.setEnabled(is_custom_armor) + self.protection_acid_edit.setEnabled(is_custom_armor) + self.protection_elec_edit.setEnabled(is_custom_armor) - self.heal_combo.setCurrentText("-- Custom --") + self.heal_combo.setCurrentText(config.heal_name if hasattr(config, 'heal_name') else "-- Custom --") self.heal_cost_edit.set_decimal(config.heal_cost_pec) + # Enable/disable based on whether it's custom healing + is_custom_heal = (config.heal_name if hasattr(config, 'heal_name') else "-- Custom --") == "-- Custom --" + self.heal_cost_edit.setEnabled(is_custom_heal) self._update_calculations() @@ -747,9 +851,42 @@ class LoadoutManagerDialog(QDialog): def _new_loadout(self): """Clear all fields for a new loadout.""" self.loadout_name_edit.clear() - self.weapon_combo.setCurrentIndex(0) - self.armor_combo.setCurrentIndex(0) - self.heal_combo.setCurrentIndex(0) + self.weapon_combo.setCurrentIndex(0) # "-- Custom --" + self.armor_combo.setCurrentIndex(0) # "-- Custom --" + self.heal_combo.setCurrentIndex(0) # "-- Custom --" + + # Clear all fields + self.weapon_damage_edit.clear() + self.weapon_decay_edit.clear() + self.weapon_ammo_edit.clear() + self.armor_decay_edit.clear() + self.protection_stab_edit.clear() + self.protection_cut_edit.clear() + self.protection_impact_edit.clear() + self.protection_pen_edit.clear() + self.protection_shrap_edit.clear() + self.protection_burn_edit.clear() + self.protection_cold_edit.clear() + self.protection_acid_edit.clear() + self.protection_elec_edit.clear() + self.heal_cost_edit.clear() + + # Enable all fields for custom entry (since "-- Custom --" is selected) + self.weapon_damage_edit.setEnabled(True) + self.weapon_decay_edit.setEnabled(True) + self.weapon_ammo_edit.setEnabled(True) + self.armor_decay_edit.setEnabled(True) + self.protection_stab_edit.setEnabled(True) + self.protection_cut_edit.setEnabled(True) + self.protection_impact_edit.setEnabled(True) + self.protection_pen_edit.setEnabled(True) + self.protection_shrap_edit.setEnabled(True) + self.protection_burn_edit.setEnabled(True) + self.protection_cold_edit.setEnabled(True) + self.protection_acid_edit.setEnabled(True) + self.protection_elec_edit.setEnabled(True) + self.heal_cost_edit.setEnabled(True) + self.mob_health_edit.set_decimal(Decimal("100")) self.current_loadout = None self._update_calculations()