""" Lemontropia Suite - Loadout Manager UI v4.0 Simplified cost-focused loadout system. """ import json import os import logging from dataclasses import dataclass, field from decimal import Decimal from pathlib import Path from typing import Optional, Dict, Any from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit, QLabel, QPushButton, QGroupBox, QMessageBox, QListWidget, QListWidgetItem, QSplitter, QWidget, QFrame, QGridLayout, ) from PyQt6.QtCore import Qt, pyqtSignal from core.nexus_full_api import get_nexus_api, NexusWeapon logger = logging.getLogger(__name__) # ============================================================================ # Simple Cost-Focused Loadout Config # ============================================================================ @dataclass class LoadoutConfig: """Simple loadout configuration focused on cost tracking. Core principle: Only store what's needed for cost calculations. Everything else is display metadata. """ # Identity name: str = "Unnamed" version: int = 2 # Version 2 = simplified format # === COST DATA (Required for tracking) === # All values in PED (not PEC) weapon_cost_per_shot: Decimal = Decimal("0") armor_cost_per_hit: Decimal = Decimal("0") healing_cost_per_heal: Decimal = Decimal("0") # === DISPLAY METADATA (For UI only) === weapon_name: str = "None" weapon_damage: Decimal = Decimal("0") weapon_decay_pec: Decimal = Decimal("0") # Raw for reference weapon_ammo_pec: Decimal = Decimal("0") # Raw for reference armor_name: str = "None" armor_decay_pec: Decimal = Decimal("0") # Raw for reference healing_name: str = "None" healing_decay_pec: Decimal = Decimal("0") # Raw for reference # === API REFERENCES (For re-loading from API) === weapon_api_id: Optional[int] = None armor_api_id: Optional[int] = None healing_api_id: Optional[int] = None def to_dict(self) -> dict: """Serialize to simple dictionary.""" return { 'name': self.name, 'version': self.version, 'weapon_cost_per_shot': str(self.weapon_cost_per_shot), 'armor_cost_per_hit': str(self.armor_cost_per_hit), 'healing_cost_per_heal': str(self.healing_cost_per_heal), 'weapon_name': self.weapon_name, 'weapon_damage': str(self.weapon_damage), 'weapon_decay_pec': str(self.weapon_decay_pec), 'weapon_ammo_pec': str(self.weapon_ammo_pec), 'armor_name': self.armor_name, 'armor_decay_pec': str(self.armor_decay_pec), 'healing_name': self.healing_name, 'healing_decay_pec': str(self.healing_decay_pec), 'weapon_api_id': self.weapon_api_id, 'armor_api_id': self.armor_api_id, 'healing_api_id': self.healing_api_id, } @classmethod def from_dict(cls, data: dict) -> "LoadoutConfig": """Deserialize from dictionary with legacy support.""" version = data.get('version', 1) if version == 1: return cls._from_legacy(data) else: return cls._from_v2(data) @classmethod def _from_v2(cls, data: dict) -> "LoadoutConfig": """Parse version 2 (current) format.""" def get_decimal(key: str, default: str = "0") -> Decimal: try: return Decimal(str(data.get(key, default))) except Exception: return Decimal(default) return cls( name=data.get('name', 'Unnamed'), version=2, weapon_cost_per_shot=get_decimal('weapon_cost_per_shot'), armor_cost_per_hit=get_decimal('armor_cost_per_hit'), healing_cost_per_heal=get_decimal('healing_cost_per_heal'), weapon_name=data.get('weapon_name', 'None'), weapon_damage=get_decimal('weapon_damage'), weapon_decay_pec=get_decimal('weapon_decay_pec'), weapon_ammo_pec=get_decimal('weapon_ammo_pec'), armor_name=data.get('armor_name', 'None'), armor_decay_pec=get_decimal('armor_decay_pec'), healing_name=data.get('healing_name', 'None'), healing_decay_pec=get_decimal('healing_decay_pec'), weapon_api_id=data.get('weapon_api_id'), armor_api_id=data.get('armor_api_id'), healing_api_id=data.get('healing_api_id'), ) @classmethod def _from_legacy(cls, data: dict) -> "LoadoutConfig": """Convert legacy format to new simple format.""" def get_decimal(key: str, default: str = "0") -> Decimal: try: return Decimal(str(data.get(key, default))) except Exception: return Decimal(default) # Calculate costs from legacy fields weapon_decay = get_decimal('weapon_decay_pec') weapon_ammo = get_decimal('weapon_ammo_pec') weapon_cost_per_shot = (weapon_decay / Decimal("100")) + (weapon_ammo * Decimal("0.0001")) armor_decay = get_decimal('armor_decay_pec') armor_cost_per_hit = armor_decay / Decimal("100") heal_decay = get_decimal('heal_cost_pec') healing_cost_per_heal = heal_decay / Decimal("100") return cls( name=data.get('name', 'Unnamed'), version=2, weapon_cost_per_shot=weapon_cost_per_shot, armor_cost_per_hit=armor_cost_per_hit, healing_cost_per_heal=healing_cost_per_heal, weapon_name=data.get('weapon_name', data.get('weapon', 'None')), weapon_damage=get_decimal('weapon_damage'), weapon_decay_pec=weapon_decay, weapon_ammo_pec=weapon_ammo, armor_name=data.get('armor_set_name', data.get('armor_name', 'None')), armor_decay_pec=armor_decay, healing_name=data.get('heal_name', 'None'), healing_decay_pec=heal_decay, ) def get_summary(self) -> Dict[str, Any]: """Get cost summary for display.""" return { 'name': self.name, 'weapon': self.weapon_name, 'armor': self.armor_name, 'healing': self.healing_name, 'cost_per_shot': self.weapon_cost_per_shot, 'cost_per_hit': self.armor_cost_per_hit, 'cost_per_heal': self.healing_cost_per_heal, } # ============================================================================ # Simple Loadout Manager Dialog # ============================================================================ class LoadoutManagerDialog(QDialog): """Simplified loadout manager focused on cost configuration.""" loadout_saved = pyqtSignal(LoadoutConfig) def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Loadout Manager") self.setMinimumSize(600, 500) # State self.config_dir = Path.home() / ".lemontropia" / "loadouts" self.config_dir.mkdir(parents=True, exist_ok=True) self.current_config: Optional[LoadoutConfig] = None # Cached API data self._cached_weapons: Optional[list] = None self._cached_armors: Optional[list] = None self._cached_healing: Optional[list] = None self._setup_ui() self._load_saved_loadouts() def _setup_ui(self): """Setup simplified UI.""" layout = QVBoxLayout(self) layout.setSpacing(10) # === Top: Loadout Name === name_layout = QHBoxLayout() name_layout.addWidget(QLabel("Loadout Name:")) self.name_edit = QLineEdit() self.name_edit.setPlaceholderText("e.g., ArMatrix Ghost Hunt") name_layout.addWidget(self.name_edit) layout.addLayout(name_layout) # === Main Content Splitter === splitter = QSplitter(Qt.Orientation.Horizontal) # Left: Saved Loadouts left_widget = QWidget() left_layout = QVBoxLayout(left_widget) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.addWidget(QLabel("Saved Loadouts:")) self.saved_list = QListWidget() self.saved_list.itemClicked.connect(self._on_loadout_selected) self.saved_list.itemDoubleClicked.connect(self._on_loadout_double_clicked) left_layout.addWidget(self.saved_list) btn_layout = QHBoxLayout() self.new_btn = QPushButton("New") self.new_btn.clicked.connect(self._new_loadout) self.delete_btn = QPushButton("Delete") self.delete_btn.clicked.connect(self._delete_loadout) btn_layout.addWidget(self.new_btn) btn_layout.addWidget(self.delete_btn) left_layout.addLayout(btn_layout) splitter.addWidget(left_widget) # Right: Configuration right_widget = QWidget() right_layout = QVBoxLayout(right_widget) right_layout.setContentsMargins(0, 0, 0, 0) # -- Weapon Section -- weapon_group = QGroupBox("⚔️ Weapon") weapon_layout = QFormLayout(weapon_group) self.weapon_btn = QPushButton("Select Weapon...") self.weapon_btn.clicked.connect(self._select_weapon) weapon_layout.addRow("Weapon:", self.weapon_btn) self.weapon_info = QLabel("None selected") self.weapon_info.setStyleSheet("color: #888;") weapon_layout.addRow(self.weapon_info) self.weapon_decay_label = QLabel("0 PEC") weapon_layout.addRow("Decay:", self.weapon_decay_label) self.weapon_ammo_label = QLabel("0") weapon_layout.addRow("Ammo:", self.weapon_ammo_label) self.weapon_cost_label = QLabel("0.0000 PED") self.weapon_cost_label.setStyleSheet("font-weight: bold; color: #7FFF7F;") weapon_layout.addRow("Cost/Shot:", self.weapon_cost_label) right_layout.addWidget(weapon_group) # -- Armor Section -- armor_group = QGroupBox("🛡️ Armor") armor_layout = QFormLayout(armor_group) self.armor_btn = QPushButton("Select Armor...") self.armor_btn.clicked.connect(self._select_armor) armor_layout.addRow("Armor:", self.armor_btn) self.armor_info = QLabel("None selected") self.armor_info.setStyleSheet("color: #888;") armor_layout.addRow(self.armor_info) self.armor_cost_label = QLabel("0.0000 PED") self.armor_cost_label.setStyleSheet("font-weight: bold; color: #7FFF7F;") armor_layout.addRow("Cost/Hit:", self.armor_cost_label) right_layout.addWidget(armor_group) # -- Healing Section -- healing_group = QGroupBox("💚 Healing") healing_layout = QFormLayout(healing_group) self.healing_btn = QPushButton("Select Healing...") self.healing_btn.clicked.connect(self._select_healing) healing_layout.addRow("Healing:", self.healing_btn) self.healing_info = QLabel("None selected") self.healing_info.setStyleSheet("color: #888;") healing_layout.addRow(self.healing_info) self.healing_cost_label = QLabel("0.0000 PED") self.healing_cost_label.setStyleSheet("font-weight: bold; color: #7FFF7F;") healing_layout.addRow("Cost/Heal:", self.healing_cost_label) right_layout.addWidget(healing_group) # -- Summary Section -- summary_group = QGroupBox("💰 Session Cost Summary") summary_layout = QGridLayout(summary_group) summary_layout.addWidget(QLabel("Cost per Shot:"), 0, 0) self.summary_shot = QLabel("0.0000 PED") summary_layout.addWidget(self.summary_shot, 0, 1) summary_layout.addWidget(QLabel("Cost per Hit:"), 1, 0) self.summary_hit = QLabel("0.0000 PED") summary_layout.addWidget(self.summary_hit, 1, 1) summary_layout.addWidget(QLabel("Cost per Heal:"), 2, 0) self.summary_heal = QLabel("0.0000 PED") summary_layout.addWidget(self.summary_heal, 2, 1) summary_layout.setColumnStretch(1, 1) right_layout.addWidget(summary_group) right_layout.addStretch() splitter.addWidget(right_widget) splitter.setSizes([200, 400]) layout.addWidget(splitter) # === Bottom Buttons === button_layout = QHBoxLayout() button_layout.addStretch() self.save_btn = QPushButton("💾 Save Loadout") self.save_btn.clicked.connect(self._save_loadout) self.save_btn.setStyleSheet(""" QPushButton { background-color: #2E7D32; color: white; padding: 8px 16px; font-weight: bold; } QPushButton:hover { background-color: #388E3C; } """) button_layout.addWidget(self.save_btn) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.reject) button_layout.addWidget(self.cancel_btn) layout.addLayout(button_layout) def _select_weapon(self): """Open weapon selector dialog.""" from ui.weapon_selector import WeaponSelectorDialog dialog = WeaponSelectorDialog(self) if dialog.exec() == QDialog.DialogCode.Accepted: weapon = dialog.get_selected_weapon() if weapon: self._set_weapon(weapon) def _set_weapon(self, weapon: NexusWeapon): """Set weapon and calculate cost.""" # Calculate cost per shot decay_pec = Decimal(str(weapon.decay)) ammo = Decimal(str(weapon.ammo_burn)) cost_per_shot = (decay_pec / Decimal("100")) + (ammo * Decimal("0.0001")) # Update UI self.weapon_btn.setText(weapon.name[:30]) self.weapon_info.setText(f"Damage: {weapon.damage} | Range: {weapon.range_val}") self.weapon_decay_label.setText(f"{decay_pec} PEC") self.weapon_ammo_label.setText(f"{ammo}") self.weapon_cost_label.setText(f"{cost_per_shot:.4f} PED") # Store for saving self._pending_weapon = { 'name': weapon.name, 'api_id': weapon.id, 'damage': weapon.damage, 'decay_pec': decay_pec, 'ammo_pec': ammo, 'cost_per_shot': cost_per_shot, } self._update_summary() def _select_armor(self): """Open simplified armor selector.""" from ui.armor_selector import ArmorSelectorDialog dialog = ArmorSelectorDialog(self) if dialog.exec() == QDialog.DialogCode.Accepted: result = dialog.get_selected_armor() if result: self._set_armor(result) def _set_armor(self, armor_data: dict): """Set armor and calculate cost.""" # armor_data has: name, decay_pec, protection_summary name = armor_data.get('name', 'Unknown') decay_pec = Decimal(str(armor_data.get('decay_pec', 0))) cost_per_hit = decay_pec / Decimal("100") # Update UI self.armor_btn.setText(name[:30]) prot_summary = armor_data.get('protection_summary', '') self.armor_info.setText(prot_summary[:50] if prot_summary else "No protection data") self.armor_cost_label.setText(f"{cost_per_hit:.4f} PED") # Store for saving self._pending_armor = { 'name': name, 'api_id': armor_data.get('api_id'), 'decay_pec': decay_pec, 'cost_per_hit': cost_per_hit, } self._update_summary() def _select_healing(self): """Open healing selector.""" from ui.healing_selector import HealingSelectorDialog dialog = HealingSelectorDialog(self) if dialog.exec() == QDialog.DialogCode.Accepted: healing = dialog.get_selected_healing() if healing: self._set_healing(healing) def _set_healing(self, healing_data: dict): """Set healing and calculate cost.""" name = healing_data.get('name', 'Unknown') decay_pec = Decimal(str(healing_data.get('decay_pec', 0))) heal_amount = Decimal(str(healing_data.get('heal_amount', 0))) cost_per_heal = decay_pec / Decimal("100") # Update UI self.healing_btn.setText(name[:30]) self.healing_info.setText(f"Heal: {heal_amount} HP") self.healing_cost_label.setText(f"{cost_per_heal:.4f} PED") # Store for saving self._pending_healing = { 'name': name, 'api_id': healing_data.get('api_id'), 'decay_pec': decay_pec, 'heal_amount': heal_amount, 'cost_per_heal': cost_per_heal, } self._update_summary() def _update_summary(self): """Update cost summary display.""" shot = getattr(self, '_pending_weapon', {}).get('cost_per_shot', Decimal("0")) hit = getattr(self, '_pending_armor', {}).get('cost_per_hit', Decimal("0")) heal = getattr(self, '_pending_healing', {}).get('cost_per_heal', Decimal("0")) self.summary_shot.setText(f"{shot:.4f} PED") self.summary_hit.setText(f"{hit:.4f} PED") self.summary_heal.setText(f"{heal:.4f} PED") def _save_loadout(self): """Save current configuration.""" name = self.name_edit.text().strip() if not name: QMessageBox.warning(self, "Missing Name", "Please enter a loadout name") return # Build config from pending data weapon = getattr(self, '_pending_weapon', {}) armor = getattr(self, '_pending_armor', {}) healing = getattr(self, '_pending_healing', {}) config = LoadoutConfig( name=name, weapon_cost_per_shot=weapon.get('cost_per_shot', Decimal("0")), armor_cost_per_hit=armor.get('cost_per_hit', Decimal("0")), healing_cost_per_heal=healing.get('cost_per_heal', Decimal("0")), weapon_name=weapon.get('name', 'None'), weapon_damage=weapon.get('damage', Decimal("0")), weapon_decay_pec=weapon.get('decay_pec', Decimal("0")), weapon_ammo_pec=weapon.get('ammo_pec', Decimal("0")), armor_name=armor.get('name', 'None'), armor_decay_pec=armor.get('decay_pec', Decimal("0")), healing_name=healing.get('name', 'None'), healing_decay_pec=healing.get('decay_pec', Decimal("0")), weapon_api_id=weapon.get('api_id'), armor_api_id=armor.get('api_id'), healing_api_id=healing.get('api_id'), ) # Save to file safe_name = "".join(c for c in name if c.isalnum() or c in "._- ").strip() if not safe_name: safe_name = "unnamed" filepath = self.config_dir / f"{safe_name}.json" try: with open(filepath, 'w') as f: json.dump(config.to_dict(), f, indent=2) self.current_config = config self.loadout_saved.emit(config) self._load_saved_loadouts() QMessageBox.information(self, "Saved", f"Loadout '{name}' saved!") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save: {e}") def _load_saved_loadouts(self): """Load list of saved loadouts.""" self.saved_list.clear() try: for filepath in sorted(self.config_dir.glob("*.json")): try: with open(filepath, 'r') as f: data = json.load(f) config = LoadoutConfig.from_dict(data) item = QListWidgetItem(f"📋 {config.name}") item.setData(Qt.ItemDataRole.UserRole, str(filepath)) # Tooltip with costs tooltip = ( f"Weapon: {config.weapon_name}\n" f"Armor: {config.armor_name}\n" f"Cost/Shot: {config.weapon_cost_per_shot:.4f} PED\n" f"Cost/Hit: {config.armor_cost_per_hit:.4f} PED\n" f"Cost/Heal: {config.healing_cost_per_heal:.4f} PED" ) item.setToolTip(tooltip) self.saved_list.addItem(item) except Exception as e: logger.error(f"Failed to load {filepath}: {e}") except Exception as e: logger.error(f"Failed to list loadouts: {e}") def _on_loadout_selected(self, item: QListWidgetItem): """Load selected loadout into UI.""" filepath = item.data(Qt.ItemDataRole.UserRole) if not filepath: return try: with open(filepath, 'r') as f: data = json.load(f) config = LoadoutConfig.from_dict(data) self._load_config_into_ui(config) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load: {e}") def _on_loadout_double_clicked(self, item: QListWidgetItem): """Double-click to select and close.""" self._on_loadout_selected(item) self.accept() def _load_config_into_ui(self, config: LoadoutConfig): """Load config values into UI fields.""" self.name_edit.setText(config.name) # Weapon if config.weapon_name != "None": self.weapon_btn.setText(config.weapon_name[:30]) self.weapon_info.setText(f"Damage: {config.weapon_damage}") self.weapon_decay_label.setText(f"{config.weapon_decay_pec} PEC") self.weapon_ammo_label.setText(f"{config.weapon_ammo_pec}") self.weapon_cost_label.setText(f"{config.weapon_cost_per_shot:.4f} PED") self._pending_weapon = { 'name': config.weapon_name, 'api_id': config.weapon_api_id, 'damage': config.weapon_damage, 'decay_pec': config.weapon_decay_pec, 'ammo_pec': config.weapon_ammo_pec, 'cost_per_shot': config.weapon_cost_per_shot, } # Armor if config.armor_name != "None": self.armor_btn.setText(config.armor_name[:30]) self.armor_info.setText("Loaded from save") self.armor_cost_label.setText(f"{config.armor_cost_per_hit:.4f} PED") self._pending_armor = { 'name': config.armor_name, 'api_id': config.armor_api_id, 'decay_pec': config.armor_decay_pec, 'cost_per_hit': config.armor_cost_per_hit, } # Healing if config.healing_name != "None": self.healing_btn.setText(config.healing_name[:30]) self.healing_cost_label.setText(f"{config.healing_cost_per_heal:.4f} PED") self._pending_healing = { 'name': config.healing_name, 'api_id': config.healing_api_id, 'decay_pec': config.healing_decay_pec, 'cost_per_heal': config.healing_cost_per_heal, } self._update_summary() self.current_config = config def _new_loadout(self): """Clear all fields for new loadout.""" self.name_edit.clear() self.weapon_btn.setText("Select Weapon...") self.weapon_info.setText("None selected") self.weapon_decay_label.setText("0 PEC") self.weapon_ammo_label.setText("0") self.weapon_cost_label.setText("0.0000 PED") self.armor_btn.setText("Select Armor...") self.armor_info.setText("None selected") self.armor_cost_label.setText("0.0000 PED") self.healing_btn.setText("Select Healing...") self.healing_info.setText("None selected") self.healing_cost_label.setText("0.0000 PED") self._pending_weapon = None self._pending_armor = None self._pending_healing = None self._update_summary() self.current_config = None def _delete_loadout(self): """Delete selected loadout.""" item = self.saved_list.currentItem() if not item: QMessageBox.information(self, "No Selection", "Please select a loadout to delete") return filepath = item.data(Qt.ItemDataRole.UserRole) name = item.text().replace("📋 ", "") reply = QMessageBox.question( self, "Confirm Delete", f"Delete '{name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: os.remove(filepath) self._load_saved_loadouts() self._new_loadout() except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete: {e}") def get_config(self) -> Optional[LoadoutConfig]: """Get current configuration.""" return self.current_config # ============================================================================ # Backward Compatibility # ============================================================================ # Keep old names for imports LoadoutManager = LoadoutManagerDialog