""" Lemontropia Suite - Loadout Manager UI v2.0 Full API integration with Entropia Nexus and complete attachment support. """ import json import os import logging from dataclasses import dataclass, asdict, field from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Optional, List, Dict, Any from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit, QComboBox, QLabel, QPushButton, QGroupBox, QSpinBox, QMessageBox, QListWidget, QListWidgetItem, QSplitter, QWidget, QFrame, QScrollArea, QGridLayout, QCheckBox, QDialogButtonBox, QTreeWidget, QTreeWidgetItem, QHeaderView, QTabWidget, QProgressDialog ) from PyQt6.QtCore import Qt, pyqtSignal, QThread from PyQt6.QtGui import QFont from core.nexus_api import EntropiaNexusAPI, WeaponStats, ArmorStats, FinderStats from core.attachments import ( Attachment, WeaponAmplifier, WeaponScope, WeaponAbsorber, ArmorPlating, Enhancer, can_attach, get_mock_attachments ) logger = logging.getLogger(__name__) # ============================================================================ # Data Structures # ============================================================================ @dataclass class AttachmentConfig: """Configuration for an equipped attachment.""" name: str item_id: str attachment_type: str decay_pec: Decimal # Effect values damage_bonus: Decimal = Decimal("0") range_bonus: Decimal = Decimal("0") efficiency_bonus: Decimal = Decimal("0") protection_bonus: Dict[str, Decimal] = field(default_factory=dict) def to_dict(self) -> dict: return { 'name': self.name, 'item_id': self.item_id, 'attachment_type': self.attachment_type, 'decay_pec': str(self.decay_pec), 'damage_bonus': str(self.damage_bonus), 'range_bonus': str(self.range_bonus), 'efficiency_bonus': str(self.efficiency_bonus), 'protection_bonus': {k: str(v) for k, v in self.protection_bonus.items()}, } @classmethod def from_dict(cls, data: dict) -> "AttachmentConfig": return cls( name=data['name'], item_id=data['item_id'], attachment_type=data['attachment_type'], decay_pec=Decimal(data['decay_pec']), damage_bonus=Decimal(data.get('damage_bonus', '0')), range_bonus=Decimal(data.get('range_bonus', '0')), efficiency_bonus=Decimal(data.get('efficiency_bonus', '0')), protection_bonus={k: Decimal(v) for k, v in data.get('protection_bonus', {}).items()}, ) @dataclass class LoadoutConfig: """Configuration for a hunting loadout with full gear and attachments.""" name: str # Weapon weapon_name: str weapon_id: int = 0 weapon_damage: Decimal = Decimal("0") weapon_decay_pec: Decimal = Decimal("0") weapon_ammo_pec: Decimal = Decimal("0") weapon_dpp: Decimal = Decimal("0") # Weapon Attachments weapon_amplifier: Optional[AttachmentConfig] = None weapon_scope: Optional[AttachmentConfig] = None weapon_absorber: Optional[AttachmentConfig] = None # Armor armor_name: str = "-- None --" armor_id: int = 0 armor_decay_pec: Decimal = Decimal("0") protection_stab: Decimal = Decimal("0") protection_cut: Decimal = Decimal("0") protection_impact: Decimal = Decimal("0") protection_penetration: Decimal = Decimal("0") protection_shrapnel: Decimal = Decimal("0") protection_burn: Decimal = Decimal("0") protection_cold: Decimal = Decimal("0") protection_acid: Decimal = Decimal("0") protection_electric: Decimal = Decimal("0") # Armor Plating armor_plating: Optional[AttachmentConfig] = None # Healing heal_name: str = "-- Custom --" heal_cost_pec: Decimal = Decimal("2.0") heal_amount: Decimal = Decimal("20") # Settings shots_per_hour: int = 3600 hits_per_hour: int = 720 # Estimate: 1 hit per 5 shots heals_per_hour: int = 60 # Estimate: 1 heal per minute def get_total_damage(self) -> Decimal: """Calculate total damage including amplifier.""" base = self.weapon_damage if self.weapon_amplifier: base += self.weapon_amplifier.damage_bonus return base def get_total_decay_per_shot(self) -> Decimal: """Calculate total decay per shot including attachments.""" total = self.weapon_decay_pec if self.weapon_amplifier: total += self.weapon_amplifier.decay_pec if self.weapon_scope: total += self.weapon_scope.decay_pec if self.weapon_absorber: total += self.weapon_absorber.decay_pec return total def get_total_ammo_per_shot(self) -> Decimal: """Calculate total ammo cost per shot in PEC. Note: ammo_burn from API is in ammo units (1 ammo = 0.01 PEC) """ # Convert ammo units to PEC (1 ammo = 0.01 PEC) total = self.weapon_ammo_pec * Decimal("0.01") if self.weapon_amplifier: # Amplifiers add ammo cost based on damage increase # Rough estimate: 0.2 PEC per damage point for amp total += self.weapon_amplifier.damage_bonus * Decimal("0.2") return total def calculate_dpp(self) -> Decimal: """Calculate Damage Per Pec (DPP) with all attachments.""" total_damage = self.get_total_damage() total_cost = self.get_total_decay_per_shot() + self.get_total_ammo_per_shot() if total_cost == 0: return Decimal("0") return total_damage / total_cost def calculate_weapon_cost_per_hour(self) -> Decimal: """Calculate weapon cost per hour.""" cost_per_shot = self.get_total_decay_per_shot() + self.get_total_ammo_per_shot() return cost_per_shot * Decimal(self.shots_per_hour) def calculate_armor_cost_per_hour(self) -> Decimal: """Calculate armor cost per hour including plating.""" total_decay = self.armor_decay_pec if self.armor_plating: total_decay += self.armor_plating.decay_pec return total_decay * Decimal(self.hits_per_hour) def calculate_heal_cost_per_hour(self) -> Decimal: """Calculate healing cost per hour.""" return self.heal_cost_pec * Decimal(self.heals_per_hour) def calculate_total_cost_per_hour(self) -> Decimal: """Calculate total PED cost per hour including all gear and attachments.""" weapon_cost = self.calculate_weapon_cost_per_hour() armor_cost = self.calculate_armor_cost_per_hour() heal_cost = self.calculate_heal_cost_per_hour() total_pec = weapon_cost + armor_cost + heal_cost return total_pec / Decimal("100") # Convert PEC to PED def calculate_break_even(self, mob_health: Decimal) -> Decimal: """Calculate break-even loot value for a mob with given health.""" total_damage = self.get_total_damage() shots_to_kill = mob_health / total_damage if total_damage > 0 else Decimal("1") if shots_to_kill < 1: shots_to_kill = Decimal("1") cost_per_shot = self.get_total_decay_per_shot() + self.get_total_ammo_per_shot() total_cost_pec = shots_to_kill * cost_per_shot return total_cost_pec / Decimal("100") # Convert to PED def get_total_protection(self) -> Dict[str, Decimal]: """Get total protection values including plating.""" protections = { 'stab': self.protection_stab, 'cut': self.protection_cut, 'impact': self.protection_impact, 'penetration': self.protection_penetration, 'shrapnel': self.protection_shrapnel, 'burn': self.protection_burn, 'cold': self.protection_cold, 'acid': self.protection_acid, 'electric': self.protection_electric, } if self.armor_plating and self.armor_plating.protection_bonus: for dmg_type, value in self.armor_plating.protection_bonus.items(): if dmg_type in protections: protections[dmg_type] += value return protections def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" data = { k: str(v) if isinstance(v, Decimal) else v for k, v in asdict(self).items() } # Handle attachment configs if self.weapon_amplifier: data['weapon_amplifier'] = self.weapon_amplifier.to_dict() if self.weapon_scope: data['weapon_scope'] = self.weapon_scope.to_dict() if self.weapon_absorber: data['weapon_absorber'] = self.weapon_absorber.to_dict() if self.armor_plating: data['armor_plating'] = self.armor_plating.to_dict() return data @classmethod def from_dict(cls, data: dict) -> "LoadoutConfig": """Create LoadoutConfig from dictionary.""" decimal_fields = [ 'weapon_damage', 'weapon_decay_pec', 'weapon_ammo_pec', 'weapon_dpp', 'armor_decay_pec', 'heal_cost_pec', 'heal_amount', 'protection_stab', 'protection_cut', 'protection_impact', 'protection_penetration', 'protection_shrapnel', 'protection_burn', 'protection_cold', 'protection_acid', 'protection_electric' ] for field in decimal_fields: if field in data: data[field] = Decimal(data[field]) # Handle integer fields int_fields = ['weapon_id', 'armor_id', 'shots_per_hour', 'hits_per_hour', 'heals_per_hour'] for field in int_fields: if field in data: data[field] = int(data[field]) # Handle attachment configs if 'weapon_amplifier' in data and data['weapon_amplifier']: data['weapon_amplifier'] = AttachmentConfig.from_dict(data['weapon_amplifier']) else: data['weapon_amplifier'] = None if 'weapon_scope' in data and data['weapon_scope']: data['weapon_scope'] = AttachmentConfig.from_dict(data['weapon_scope']) else: data['weapon_scope'] = None if 'weapon_absorber' in data and data['weapon_absorber']: data['weapon_absorber'] = AttachmentConfig.from_dict(data['weapon_absorber']) else: data['weapon_absorber'] = None if 'armor_plating' in data and data['armor_plating']: data['armor_plating'] = AttachmentConfig.from_dict(data['armor_plating']) else: data['armor_plating'] = None # Handle legacy configs if 'heal_name' not in data: data['heal_name'] = '-- Custom --' return cls(**data) # ============================================================================ # Mock Data for Healing # ============================================================================ MOCK_HEALING = [ {"name": "Vivo T10", "cost": Decimal("2.0"), "amount": Decimal("12")}, {"name": "Vivo T15", "cost": Decimal("3.5"), "amount": Decimal("18")}, {"name": "Vivo S10", "cost": Decimal("4.0"), "amount": Decimal("25")}, {"name": "Refurbished H.E.A.R.T.", "cost": Decimal("1.5"), "amount": Decimal("8")}, {"name": "Restoration Chip I", "cost": Decimal("5.0"), "amount": Decimal("30")}, {"name": "Restoration Chip II", "cost": Decimal("8.0"), "amount": Decimal("50")}, {"name": "Restoration Chip III", "cost": Decimal("12.0"), "amount": Decimal("80")}, {"name": "Mod 2350", "cost": Decimal("15.0"), "amount": Decimal("100")}, ] # ============================================================================ # Custom Widgets # ============================================================================ class DecimalLineEdit(QLineEdit): """Line edit with decimal validation.""" def __init__(self, parent=None): super().__init__(parent) self.setPlaceholderText("0.00") def get_decimal(self) -> Decimal: """Get value as Decimal, returns 0 on invalid input.""" text = self.text().strip() if not text: return Decimal("0") try: return Decimal(text) except InvalidOperation: return Decimal("0") def set_decimal(self, value: Decimal): """Set value from Decimal.""" self.setText(str(value)) class DarkGroupBox(QGroupBox): """Group box with dark theme styling.""" def __init__(self, title: str, parent=None): super().__init__(title, parent) self.setStyleSheet(""" QGroupBox { color: #e0e0e0; border: 2px solid #3d3d3d; border-radius: 6px; margin-top: 10px; padding-top: 10px; font-weight: bold; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; } """) # ============================================================================ # Attachment Selector Dialog # ============================================================================ class AttachmentSelectorDialog(QDialog): """Dialog for selecting attachments.""" attachment_selected = pyqtSignal(object) # AttachmentConfig def __init__(self, attachment_type: str, parent=None): super().__init__(parent) self.attachment_type = attachment_type self.selected_attachment = None self.setWindowTitle(f"Select {attachment_type.title()}") self.setMinimumSize(500, 400) self._setup_ui() self._load_attachments() def _setup_ui(self): layout = QVBoxLayout(self) # Header header = QLabel(f"Select a {self.attachment_type.title()}") header.setFont(QFont("Arial", 12, QFont.Weight.Bold)) layout.addWidget(header) # Attachment list self.list_widget = QListWidget() self.list_widget.itemSelectionChanged.connect(self._on_selection_changed) self.list_widget.itemDoubleClicked.connect(self._on_double_click) layout.addWidget(self.list_widget) # Stats preview self.preview_label = QLabel("Select an attachment to view stats") self.preview_label.setStyleSheet("color: #888888; padding: 10px;") layout.addWidget(self.preview_label) # Buttons button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) button_box.accepted.connect(self._on_accept) button_box.rejected.connect(self.reject) self.ok_btn = button_box.button(QDialogButtonBox.StandardButton.Ok) self.ok_btn.setEnabled(False) layout.addWidget(button_box) # None option self.none_btn = QPushButton("Remove Attachment") self.none_btn.clicked.connect(self._on_none) layout.addWidget(self.none_btn) def _load_attachments(self): """Load attachments from mock data.""" attachments = get_mock_attachments(self.attachment_type) self.attachments = attachments self.list_widget.clear() for att in attachments: item = QListWidgetItem(f"📎 {att.name}") item.setData(Qt.ItemDataRole.UserRole, att) # Add tooltip with stats tooltip = f"Decay: {att.decay_pec} PEC\n" if hasattr(att, 'damage_increase'): tooltip += f"Damage: +{att.damage_increase}" elif hasattr(att, 'range_increase'): tooltip += f"Range: +{att.range_increase}m" elif hasattr(att, 'damage_reduction'): tooltip += f"Protection: +{att.damage_reduction}" elif hasattr(att, 'protection_impact'): tooltip += f"Impact: +{att.protection_impact}" item.setToolTip(tooltip) self.list_widget.addItem(item) def _on_selection_changed(self): """Handle selection change.""" selected = self.list_widget.selectedItems() if selected: attachment = selected[0].data(Qt.ItemDataRole.UserRole) self.selected_attachment = attachment self.ok_btn.setEnabled(True) self._update_preview(attachment) def _update_preview(self, attachment): """Update preview label.""" text = f"{attachment.name}
" text += f"Decay: {attachment.decay_pec} PEC
" if isinstance(attachment, WeaponAmplifier): text += f"Damage Increase: +{attachment.damage_increase}
" text += f"Ammo Increase: +{attachment.ammo_increase}" elif isinstance(attachment, WeaponScope): text += f"Range Increase: +{attachment.range_increase}m" elif isinstance(attachment, WeaponAbsorber): text += f"Damage Reduction: -{attachment.damage_reduction}" elif isinstance(attachment, ArmorPlating): text += f"Total Protection: +{attachment.get_total_protection()}" self.preview_label.setText(text) self.preview_label.setStyleSheet("color: #e0e0e0; padding: 10px; background: #2d2d2d; border-radius: 4px;") def _on_double_click(self, item): """Handle double click.""" self._on_accept() def _on_accept(self): """Handle OK button.""" if self.selected_attachment: att = self.selected_attachment # Build protection bonus dict for armor plating protection_bonus = {} if isinstance(att, ArmorPlating): protection_bonus = { 'stab': att.protection_stab, 'cut': att.protection_cut, 'impact': att.protection_impact, 'penetration': att.protection_penetration, 'shrapnel': att.protection_shrapnel, 'burn': att.protection_burn, 'cold': att.protection_cold, 'acid': att.protection_acid, 'electric': att.protection_electric, } config = AttachmentConfig( name=att.name, item_id=att.item_id, attachment_type=att.attachment_type, decay_pec=att.decay_pec, damage_bonus=getattr(att, 'damage_increase', Decimal("0")), range_bonus=getattr(att, 'range_increase', Decimal("0")), efficiency_bonus=Decimal("0"), protection_bonus=protection_bonus, ) self.attachment_selected.emit(config) self.accept() def _on_none(self): """Remove attachment.""" self.attachment_selected.emit(None) self.accept() # ============================================================================ # Gear Loader Threads # ============================================================================ class WeaponLoaderThread(QThread): """Thread to load weapons from API.""" weapons_loaded = pyqtSignal(list) error_occurred = pyqtSignal(str) def run(self): try: api = EntropiaNexusAPI() weapons = api.get_all_weapons() self.weapons_loaded.emit(weapons) except Exception as e: logger.error(f"Failed to load weapons: {e}") self.error_occurred.emit(str(e)) class ArmorLoaderThread(QThread): """Thread to load armors from API.""" armors_loaded = pyqtSignal(list) error_occurred = pyqtSignal(str) def run(self): try: api = EntropiaNexusAPI() armors = api.get_all_armors() self.armors_loaded.emit(armors) except Exception as e: logger.error(f"Failed to load armors: {e}") self.error_occurred.emit(str(e)) # ============================================================================ # Weapon Selector Dialog # ============================================================================ class WeaponSelectorDialog(QDialog): """Dialog for selecting weapons from Entropia Nexus API.""" weapon_selected = pyqtSignal(object) # WeaponStats def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Select Weapon - Entropia Nexus") self.setMinimumSize(900, 600) self.weapons = [] self.selected_weapon = None self.api = EntropiaNexusAPI() self._setup_ui() self._load_data() def _setup_ui(self): layout = QVBoxLayout(self) layout.setSpacing(10) # Status self.status_label = QLabel("Loading weapons from Entropia Nexus...") layout.addWidget(self.status_label) # Search search_layout = QHBoxLayout() search_layout.addWidget(QLabel("Search:")) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Search weapons by name...") self.search_input.returnPressed.connect(self._on_search) search_layout.addWidget(self.search_input) self.search_btn = QPushButton("Search") self.search_btn.clicked.connect(self._on_search) search_layout.addWidget(self.search_btn) layout.addLayout(search_layout) # Results tree self.results_tree = QTreeWidget() self.results_tree.setHeaderLabels([ "Name", "Type", "Category", "Damage", "DPP", "Decay", "Ammo", "Cost/h" ]) header = self.results_tree.header() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) for i in range(1, 8): header.setSectionResizeMode(i, QHeaderView.ResizeMode.Fixed) header.resizeSection(1, 80) header.resizeSection(2, 80) header.resizeSection(3, 60) header.resizeSection(4, 60) header.resizeSection(5, 70) header.resizeSection(6, 60) header.resizeSection(7, 70) self.results_tree.setAlternatingRowColors(True) self.results_tree.itemSelectionChanged.connect(self._on_selection_changed) self.results_tree.itemDoubleClicked.connect(self._on_double_click) layout.addWidget(self.results_tree) # Stats preview self.preview_group = DarkGroupBox("Weapon Stats") self.preview_layout = QFormLayout(self.preview_group) self.preview_layout.addRow("Select a weapon to view stats", QLabel("")) layout.addWidget(self.preview_group) # Buttons button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) button_box.accepted.connect(self._on_accept) button_box.rejected.connect(self.reject) self.ok_btn = button_box.button(QDialogButtonBox.StandardButton.Ok) self.ok_btn.setEnabled(False) self.ok_btn.setText("Select Weapon") layout.addWidget(button_box) def _load_data(self): """Load weapons asynchronously.""" self.loader = WeaponLoaderThread() self.loader.weapons_loaded.connect(self._on_data_loaded) self.loader.error_occurred.connect(self._on_load_error) self.loader.start() def _on_data_loaded(self, weapons): """Handle loaded weapons.""" self.weapons = weapons self.status_label.setText(f"Loaded {len(weapons):,} weapons from Entropia Nexus") self._populate_tree(weapons[:200]) # Show first 200 def _on_load_error(self, error): """Handle load error.""" self.status_label.setText(f"Error loading weapons: {error}") QMessageBox.critical(self, "Error", f"Failed to load weapons: {error}") def _populate_tree(self, weapons): """Populate tree with weapons.""" self.results_tree.clear() for w in weapons: item = QTreeWidgetItem([ w.name, w.type, w.category, str(w.total_damage), f"{w.dpp:.2f}", f"{w.decay:.2f}" if w.decay else "-", str(w.ammo_burn) if w.ammo_burn else "-", f"{w.cost_per_hour:.0f}" ]) item.setData(0, Qt.ItemDataRole.UserRole, w) self.results_tree.addTopLevelItem(item) def _on_search(self): """Search weapons.""" query = self.search_input.text().strip().lower() if not query: self._populate_tree(self.weapons[:200]) return results = [w for w in self.weapons if query in w.name.lower()] self._populate_tree(results) self.status_label.setText(f"Found {len(results)} weapons matching '{query}'") def _on_selection_changed(self): """Handle selection change.""" selected = self.results_tree.selectedItems() if selected: weapon = selected[0].data(0, Qt.ItemDataRole.UserRole) self.selected_weapon = weapon self.ok_btn.setEnabled(True) self._update_preview(weapon) else: self.selected_weapon = None self.ok_btn.setEnabled(False) def _update_preview(self, w): """Update stats preview.""" while self.preview_layout.rowCount() > 0: self.preview_layout.removeRow(0) self.preview_layout.addRow("Name:", QLabel(w.name)) self.preview_layout.addRow("Type:", QLabel(f"{w.type} {w.category}")) self.preview_layout.addRow("Damage:", QLabel(str(w.total_damage))) self.preview_layout.addRow("DPP:", QLabel(f"{w.dpp:.3f}")) self.preview_layout.addRow("Decay:", QLabel(f"{w.decay:.3f} PEC/shot" if w.decay else "-")) self.preview_layout.addRow("Ammo:", QLabel(f"{w.ammo_burn} units/shot" if w.ammo_burn else "-")) self.preview_layout.addRow("Cost/Hour:", QLabel(f"{w.cost_per_hour:.2f} PED")) if w.efficiency: self.preview_layout.addRow("Efficiency:", QLabel(f"{w.efficiency:.1f}%")) def _on_double_click(self, item, column): """Handle double click.""" self._on_accept() def _on_accept(self): """Handle OK button.""" if self.selected_weapon: self.weapon_selected.emit(self.selected_weapon) self.accept() # ============================================================================ # Armor Selector Dialog # ============================================================================ class ArmorSelectorDialog(QDialog): """Dialog for selecting armors from Entropia Nexus API.""" armor_selected = pyqtSignal(object) # ArmorStats def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Select Armor - Entropia Nexus") self.setMinimumSize(800, 500) self.armors = [] self.selected_armor = None self.api = EntropiaNexusAPI() self._setup_ui() self._load_data() def _setup_ui(self): layout = QVBoxLayout(self) layout.setSpacing(10) # Status self.status_label = QLabel("Loading armors from Entropia Nexus...") layout.addWidget(self.status_label) # Search search_layout = QHBoxLayout() search_layout.addWidget(QLabel("Search:")) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Search armors by name...") self.search_input.returnPressed.connect(self._on_search) search_layout.addWidget(self.search_input) self.search_btn = QPushButton("Search") self.search_btn.clicked.connect(self._on_search) search_layout.addWidget(self.search_btn) layout.addLayout(search_layout) # Results tree self.results_tree = QTreeWidget() self.results_tree.setHeaderLabels([ "Name", "Impact", "Cut", "Stab", "Pen", "Burn", "Cold", "Total" ]) header = self.results_tree.header() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) for i in range(1, 8): header.setSectionResizeMode(i, QHeaderView.ResizeMode.Fixed) header.resizeSection(i, 60) self.results_tree.setAlternatingRowColors(True) self.results_tree.itemSelectionChanged.connect(self._on_selection_changed) self.results_tree.itemDoubleClicked.connect(self._on_double_click) layout.addWidget(self.results_tree) # Stats preview self.preview_group = DarkGroupBox("Armor Stats") self.preview_layout = QFormLayout(self.preview_group) self.preview_layout.addRow("Select an armor to view stats", QLabel("")) layout.addWidget(self.preview_group) # Buttons button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) button_box.accepted.connect(self._on_accept) button_box.rejected.connect(self.reject) self.ok_btn = button_box.button(QDialogButtonBox.StandardButton.Ok) self.ok_btn.setEnabled(False) self.ok_btn.setText("Select Armor") layout.addWidget(button_box) def _load_data(self): """Load armors asynchronously.""" self.loader = ArmorLoaderThread() self.loader.armors_loaded.connect(self._on_data_loaded) self.loader.error_occurred.connect(self._on_load_error) self.loader.start() def _on_data_loaded(self, armors): """Handle loaded armors.""" self.armors = armors self.status_label.setText(f"Loaded {len(armors):,} armors from Entropia Nexus") self._populate_tree(armors[:200]) def _on_load_error(self, error): """Handle load error.""" self.status_label.setText(f"Error loading armors: {error}") QMessageBox.critical(self, "Error", f"Failed to load armors: {error}") def _populate_tree(self, armors): """Populate tree with armors.""" self.results_tree.clear() for a in armors: item = QTreeWidgetItem([ a.name, str(a.protection_impact), str(a.protection_cut), str(a.protection_stab), str(a.protection_penetration), str(a.protection_burn), str(a.protection_cold), str(a.total_protection), ]) item.setData(0, Qt.ItemDataRole.UserRole, a) self.results_tree.addTopLevelItem(item) def _on_search(self): """Search armors.""" query = self.search_input.text().strip().lower() if not query: self._populate_tree(self.armors[:200]) return results = [a for a in self.armors if query in a.name.lower()] self._populate_tree(results) self.status_label.setText(f"Found {len(results)} armors matching '{query}'") def _on_selection_changed(self): """Handle selection change.""" selected = self.results_tree.selectedItems() if selected: armor = selected[0].data(0, Qt.ItemDataRole.UserRole) self.selected_armor = armor self.ok_btn.setEnabled(True) self._update_preview(armor) else: self.selected_armor = None self.ok_btn.setEnabled(False) def _update_preview(self, a): """Update stats preview.""" while self.preview_layout.rowCount() > 0: self.preview_layout.removeRow(0) self.preview_layout.addRow("Name:", QLabel(a.name)) self.preview_layout.addRow("Total Protection:", QLabel(str(a.total_protection))) self.preview_layout.addRow("Durability:", QLabel(str(a.durability))) # Protection breakdown prot_text = f"Impact: {a.protection_impact}, Cut: {a.protection_cut}, Stab: {a.protection_stab}" self.preview_layout.addRow("Protection:", QLabel(prot_text)) def _on_double_click(self, item, column): """Handle double click.""" self._on_accept() def _on_accept(self): """Handle OK button.""" if self.selected_armor: self.armor_selected.emit(self.selected_armor) self.accept() # ============================================================================ # Main Loadout Manager Dialog # ============================================================================ class LoadoutManagerDialog(QDialog): """Main dialog for managing hunting loadouts with full API integration.""" loadout_saved = pyqtSignal(str) # Emitted when loadout is saved def __init__(self, parent=None, config_dir: Optional[str] = None): super().__init__(parent) self.setWindowTitle("Lemontropia Suite - Loadout Manager v2.0") self.setMinimumSize(1000, 800) # Configuration directory if config_dir is None: self.config_dir = Path.home() / ".lemontropia" / "loadouts" else: self.config_dir = Path(config_dir) self.config_dir.mkdir(parents=True, exist_ok=True) self.current_loadout: Optional[LoadoutConfig] = None self.current_weapon: Optional[WeaponStats] = None self.current_armor: Optional[ArmorStats] = None self._apply_dark_theme() self._create_widgets() self._create_layout() self._connect_signals() self._load_saved_loadouts() self._populate_healing_data() def _apply_dark_theme(self): """Apply dark theme styling.""" self.setStyleSheet(""" QDialog { background-color: #1e1e1e; } QLabel { color: #e0e0e0; } QLineEdit { background-color: #2d2d2d; color: #e0e0e0; border: 1px solid #3d3d3d; border-radius: 4px; padding: 5px; } QLineEdit:disabled { background-color: #252525; color: #888888; border: 1px solid #2d2d2d; } QLineEdit:focus { border: 1px solid #4a90d9; } QComboBox { background-color: #2d2d2d; color: #e0e0e0; border: 1px solid #3d3d3d; border-radius: 4px; padding: 5px; min-width: 150px; } QComboBox::drop-down { border: none; } QComboBox QAbstractItemView { background-color: #2d2d2d; color: #e0e0e0; selection-background-color: #4a90d9; } QPushButton { background-color: #3d3d3d; color: #e0e0e0; border: 1px solid #4d4d4d; border-radius: 4px; padding: 8px 16px; } QPushButton:hover { background-color: #4d4d4d; } QPushButton:pressed { background-color: #5d5d5d; } QPushButton#saveButton { background-color: #2e7d32; border-color: #4caf50; } QPushButton#saveButton:hover { background-color: #4caf50; } QPushButton#deleteButton { background-color: #7d2e2e; border-color: #f44336; } QPushButton#deleteButton:hover { background-color: #f44336; } QPushButton#selectButton { background-color: #1565c0; border-color: #2196f3; } QPushButton#selectButton:hover { background-color: #2196f3; } QListWidget { background-color: #2d2d2d; color: #e0e0e0; border: 1px solid #3d3d3d; border-radius: 4px; } QListWidget::item:selected { background-color: #4a90d9; } QScrollArea { border: none; } QTabWidget::pane { border: 1px solid #3d3d3d; background-color: #1e1e1e; } QTabBar::tab { background-color: #2d2d2d; color: #e0e0e0; padding: 8px 16px; border: 1px solid #3d3d3d; } QTabBar::tab:selected { background-color: #4a90d9; } """) def _create_widgets(self): """Create all UI widgets.""" # Loadout name self.loadout_name_edit = QLineEdit() self.loadout_name_edit.setPlaceholderText("Enter loadout name...") # Activity settings self.shots_per_hour_spin = QSpinBox() self.shots_per_hour_spin.setRange(1, 20000) self.shots_per_hour_spin.setValue(3600) self.shots_per_hour_spin.setSuffix(" /hr") self.hits_per_hour_spin = QSpinBox() self.hits_per_hour_spin.setRange(0, 5000) self.hits_per_hour_spin.setValue(720) self.hits_per_hour_spin.setSuffix(" /hr") self.heals_per_hour_spin = QSpinBox() self.heals_per_hour_spin.setRange(0, 500) self.heals_per_hour_spin.setValue(60) self.heals_per_hour_spin.setSuffix(" /hr") # Weapon section self.weapon_group = DarkGroupBox("🔫 Weapon Configuration") self.select_weapon_btn = QPushButton("🔍 Select from Entropia Nexus") self.select_weapon_btn.setObjectName("selectButton") self.weapon_name_label = QLabel("No weapon selected") self.weapon_name_label.setStyleSheet("font-weight: bold; color: #4a90d9;") self.weapon_damage_edit = DecimalLineEdit() self.weapon_decay_edit = DecimalLineEdit() self.weapon_ammo_edit = DecimalLineEdit() self.dpp_label = QLabel("0.0000") self.dpp_label.setStyleSheet("color: #4caf50; font-weight: bold; font-size: 16px;") # Weapon attachments self.attach_amp_btn = QPushButton("⚡ Add Amplifier") self.attach_scope_btn = QPushButton("🔭 Add Scope") self.attach_absorber_btn = QPushButton("🛡️ Add Absorber") self.amp_label = QLabel("None") self.scope_label = QLabel("None") self.absorber_label = QLabel("None") self.remove_amp_btn = QPushButton("✕") self.remove_scope_btn = QPushButton("✕") self.remove_absorber_btn = QPushButton("✕") self.remove_amp_btn.setFixedWidth(30) self.remove_scope_btn.setFixedWidth(30) self.remove_absorber_btn.setFixedWidth(30) # Armor section self.armor_group = DarkGroupBox("🛡️ Armor Configuration") self.select_armor_btn = QPushButton("🔍 Select from Entropia Nexus") self.select_armor_btn.setObjectName("selectButton") self.armor_name_label = QLabel("No armor selected") self.armor_name_label.setStyleSheet("font-weight: bold; color: #4a90d9;") self.armor_decay_edit = DecimalLineEdit() # Protection values self.protection_stab_edit = DecimalLineEdit() self.protection_cut_edit = DecimalLineEdit() self.protection_impact_edit = DecimalLineEdit() self.protection_pen_edit = DecimalLineEdit() self.protection_shrap_edit = DecimalLineEdit() self.protection_burn_edit = DecimalLineEdit() self.protection_cold_edit = DecimalLineEdit() self.protection_acid_edit = DecimalLineEdit() self.protection_elec_edit = DecimalLineEdit() # Armor plating self.attach_plating_btn = QPushButton("🔩 Add Plating") self.plating_label = QLabel("None") self.remove_plating_btn = QPushButton("✕") self.remove_plating_btn.setFixedWidth(30) # Healing section self.heal_group = DarkGroupBox("💊 Healing Configuration") self.heal_combo = QComboBox() self.heal_cost_edit = DecimalLineEdit() self.heal_amount_edit = DecimalLineEdit() # Cost summary self.summary_group = DarkGroupBox("📊 Cost Summary") self.weapon_cost_label = QLabel("0.00 PEC/hr") self.armor_cost_label = QLabel("0.00 PEC/hr") self.heal_cost_label = QLabel("0.00 PEC/hr") self.total_cost_label = QLabel("0.00 PED/hr") self.total_cost_label.setStyleSheet("color: #ff9800; font-weight: bold; font-size: 18px;") self.total_dpp_label = QLabel("0.0000") self.total_dpp_label.setStyleSheet("color: #4caf50; font-weight: bold; font-size: 18px;") # Break-even calculator self.mob_health_edit = DecimalLineEdit() self.mob_health_edit.set_decimal(Decimal("100")) self.calc_break_even_btn = QPushButton("Calculate") self.break_even_label = QLabel("Break-even: 0.00 PED") self.break_even_label.setStyleSheet("color: #4caf50;") # Saved loadouts list self.saved_list = QListWidget() # Buttons self.save_btn = QPushButton("💾 Save Loadout") self.save_btn.setObjectName("saveButton") self.load_btn = QPushButton("📂 Load Selected") self.delete_btn = QPushButton("🗑️ Delete") self.delete_btn.setObjectName("deleteButton") self.new_btn = QPushButton("🆕 New Loadout") self.close_btn = QPushButton("❌ Close") self.refresh_btn = QPushButton("🔄 Refresh") def _create_layout(self): """Create the main layout.""" main_layout = QHBoxLayout(self) main_layout.setSpacing(15) main_layout.setContentsMargins(15, 15, 15, 15) # Left panel - Saved loadouts left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(0, 0, 0, 0) saved_label = QLabel("💼 Saved Loadouts") saved_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) left_layout.addWidget(saved_label) left_layout.addWidget(self.saved_list) left_btn_layout = QHBoxLayout() left_btn_layout.addWidget(self.load_btn) left_btn_layout.addWidget(self.delete_btn) left_layout.addLayout(left_btn_layout) left_layout.addWidget(self.refresh_btn) left_layout.addWidget(self.new_btn) left_layout.addStretch() left_layout.addWidget(self.close_btn) # Right panel - Configuration right_scroll = QScrollArea() right_scroll.setWidgetResizable(True) right_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) right_widget = QWidget() right_layout = QVBoxLayout(right_widget) right_layout.setContentsMargins(0, 0, 10, 0) # Loadout name header name_layout = QHBoxLayout() name_label = QLabel("Loadout Name:") name_label.setFont(QFont("Arial", 10, QFont.Weight.Bold)) name_layout.addWidget(name_label) name_layout.addWidget(self.loadout_name_edit, stretch=1) right_layout.addLayout(name_layout) # Activity settings activity_group = DarkGroupBox("⚙️ Activity Settings") activity_layout = QGridLayout(activity_group) activity_layout.addWidget(QLabel("Shots/Hour:"), 0, 0) activity_layout.addWidget(self.shots_per_hour_spin, 0, 1) activity_layout.addWidget(QLabel("Hits Taken/Hour:"), 0, 2) activity_layout.addWidget(self.hits_per_hour_spin, 0, 3) activity_layout.addWidget(QLabel("Heals/Hour:"), 0, 4) activity_layout.addWidget(self.heals_per_hour_spin, 0, 5) right_layout.addWidget(activity_group) # Weapon configuration weapon_layout = QFormLayout(self.weapon_group) weapon_select_layout = QHBoxLayout() weapon_select_layout.addWidget(self.select_weapon_btn) weapon_select_layout.addWidget(self.weapon_name_label, stretch=1) weapon_layout.addRow("Weapon:", weapon_select_layout) weapon_layout.addRow("Damage:", self.weapon_damage_edit) weapon_layout.addRow("Decay/shot (PEC):", self.weapon_decay_edit) weapon_layout.addRow("Ammo/shot (PEC):", self.weapon_ammo_edit) weapon_layout.addRow("Total DPP:", self.dpp_label) # Attachments attachments_frame = QFrame() attachments_layout = QGridLayout(attachments_frame) attachments_layout.addWidget(QLabel("Amplifier:"), 0, 0) attachments_layout.addWidget(self.amp_label, 0, 1) attachments_layout.addWidget(self.attach_amp_btn, 0, 2) attachments_layout.addWidget(self.remove_amp_btn, 0, 3) attachments_layout.addWidget(QLabel("Scope:"), 1, 0) attachments_layout.addWidget(self.scope_label, 1, 1) attachments_layout.addWidget(self.attach_scope_btn, 1, 2) attachments_layout.addWidget(self.remove_scope_btn, 1, 3) attachments_layout.addWidget(QLabel("Absorber:"), 2, 0) attachments_layout.addWidget(self.absorber_label, 2, 1) attachments_layout.addWidget(self.attach_absorber_btn, 2, 2) attachments_layout.addWidget(self.remove_absorber_btn, 2, 3) weapon_layout.addRow("Attachments:", attachments_frame) right_layout.addWidget(self.weapon_group) # Armor configuration armor_layout = QFormLayout(self.armor_group) armor_select_layout = QHBoxLayout() armor_select_layout.addWidget(self.select_armor_btn) armor_select_layout.addWidget(self.armor_name_label, stretch=1) armor_layout.addRow("Armor:", armor_select_layout) armor_layout.addRow("Decay/hit (PEC):", self.armor_decay_edit) # Protection grid protection_frame = QFrame() protection_layout = QGridLayout(protection_frame) protection_layout.setSpacing(5) protections = [ ("Stab:", self.protection_stab_edit), ("Cut:", self.protection_cut_edit), ("Impact:", self.protection_impact_edit), ("Penetration:", self.protection_pen_edit), ("Shrapnel:", self.protection_shrap_edit), ("Burn:", self.protection_burn_edit), ("Cold:", self.protection_cold_edit), ("Acid:", self.protection_acid_edit), ("Electric:", self.protection_elec_edit), ] for i, (label, edit) in enumerate(protections): row = i // 3 col = (i % 3) * 2 protection_layout.addWidget(QLabel(label), row, col) protection_layout.addWidget(edit, row, col + 1) armor_layout.addRow("Protection Values:", protection_frame) # Plating plating_layout = QHBoxLayout() plating_layout.addWidget(QLabel("Armor Plating:")) plating_layout.addWidget(self.plating_label) plating_layout.addWidget(self.attach_plating_btn) plating_layout.addWidget(self.remove_plating_btn) armor_layout.addRow("", plating_layout) right_layout.addWidget(self.armor_group) # Healing configuration heal_layout = QFormLayout(self.heal_group) heal_layout.addRow("Healing Tool:", self.heal_combo) heal_layout.addRow("Cost/heal (PEC):", self.heal_cost_edit) heal_layout.addRow("Heal amount:", self.heal_amount_edit) right_layout.addWidget(self.heal_group) # Cost summary summary_layout = QFormLayout(self.summary_group) summary_layout.addRow("Weapon Cost:", self.weapon_cost_label) summary_layout.addRow("Armor Cost:", self.armor_cost_label) summary_layout.addRow("Healing Cost:", self.heal_cost_label) summary_layout.addRow("Total DPP:", self.total_dpp_label) summary_layout.addRow("Total Cost:", self.total_cost_label) break_even_layout = QHBoxLayout() break_even_layout.addWidget(QLabel("Mob Health:")) break_even_layout.addWidget(self.mob_health_edit) break_even_layout.addWidget(self.calc_break_even_btn) summary_layout.addRow("Break-Even:", break_even_layout) summary_layout.addRow("", self.break_even_label) right_layout.addWidget(self.summary_group) # Save button right_layout.addWidget(self.save_btn) right_layout.addStretch() right_scroll.setWidget(right_widget) # Splitter splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(left_panel) splitter.addWidget(right_scroll) splitter.setSizes([250, 750]) main_layout.addWidget(splitter) def _connect_signals(self): """Connect all signal handlers.""" # Weapon selection self.select_weapon_btn.clicked.connect(self._on_select_weapon) self.weapon_damage_edit.textChanged.connect(self._update_calculations) self.weapon_decay_edit.textChanged.connect(self._update_calculations) self.weapon_ammo_edit.textChanged.connect(self._update_calculations) # Armor selection self.select_armor_btn.clicked.connect(self._on_select_armor) # Attachments self.attach_amp_btn.clicked.connect(lambda: self._on_attach("amplifier")) self.attach_scope_btn.clicked.connect(lambda: self._on_attach("scope")) self.attach_absorber_btn.clicked.connect(lambda: self._on_attach("absorber")) self.attach_plating_btn.clicked.connect(lambda: self._on_attach("plating")) self.remove_amp_btn.clicked.connect(self._on_remove_amp) self.remove_scope_btn.clicked.connect(self._on_remove_scope) self.remove_absorber_btn.clicked.connect(self._on_remove_absorber) self.remove_plating_btn.clicked.connect(self._on_remove_plating) # Healing self.heal_combo.currentTextChanged.connect(self._on_heal_changed) # Activity settings self.shots_per_hour_spin.valueChanged.connect(self._update_calculations) self.hits_per_hour_spin.valueChanged.connect(self._update_calculations) self.heals_per_hour_spin.valueChanged.connect(self._update_calculations) # Buttons self.save_btn.clicked.connect(self._save_loadout) self.load_btn.clicked.connect(self._load_selected) self.delete_btn.clicked.connect(self._delete_selected) self.new_btn.clicked.connect(self._new_loadout) self.refresh_btn.clicked.connect(self._load_saved_loadouts) self.close_btn.clicked.connect(self.reject) self.calc_break_even_btn.clicked.connect(self._calculate_break_even) # Double click on list self.saved_list.itemDoubleClicked.connect(self._load_from_item) def _populate_healing_data(self): """Populate healing combo with data.""" self.heal_combo.addItem("-- Custom --") for heal in MOCK_HEALING: self.heal_combo.addItem(heal["name"]) def _on_select_weapon(self): """Open weapon selector dialog.""" dialog = WeaponSelectorDialog(self) dialog.weapon_selected.connect(self._on_weapon_selected) dialog.exec() def _on_weapon_selected(self, weapon: WeaponStats): """Handle weapon selection.""" self.current_weapon = weapon self.weapon_name_label.setText(weapon.name) self.weapon_damage_edit.set_decimal(weapon.total_damage) self.weapon_decay_edit.set_decimal(weapon.decay or Decimal("0")) self.weapon_ammo_edit.set_decimal(Decimal(weapon.ammo_burn or 0)) self._update_calculations() def _on_select_armor(self): """Open armor selector dialog.""" dialog = ArmorSelectorDialog(self) dialog.armor_selected.connect(self._on_armor_selected) dialog.exec() def _on_armor_selected(self, armor: ArmorStats): """Handle armor selection.""" self.current_armor = armor self.armor_name_label.setText(armor.name) # Calculate decay estimate (rough approximation) decay_estimate = Decimal("0.05") # Default estimate self.armor_decay_edit.set_decimal(decay_estimate) # Set protection values self.protection_stab_edit.set_decimal(armor.protection_stab) self.protection_cut_edit.set_decimal(armor.protection_cut) self.protection_impact_edit.set_decimal(armor.protection_impact) self.protection_pen_edit.set_decimal(armor.protection_penetration) self.protection_shrap_edit.set_decimal(armor.protection_shrapnel) self.protection_burn_edit.set_decimal(armor.protection_burn) self.protection_cold_edit.set_decimal(armor.protection_cold) self.protection_acid_edit.set_decimal(armor.protection_acid) self.protection_elec_edit.set_decimal(armor.protection_electric) self._update_calculations() def _on_attach(self, attachment_type: str): """Open attachment selector.""" dialog = AttachmentSelectorDialog(attachment_type, self) dialog.attachment_selected.connect(lambda att: self._on_attachment_selected(attachment_type, att)) dialog.exec() def _on_attachment_selected(self, attachment_type: str, config: Optional[AttachmentConfig]): """Handle attachment selection.""" if attachment_type == "amplifier": if config: self.current_loadout = self._get_current_config() self.current_loadout.weapon_amplifier = config self.amp_label.setText(f"{config.name} (+{config.damage_bonus} dmg)") else: if self.current_loadout: self.current_loadout.weapon_amplifier = None self.amp_label.setText("None") elif attachment_type == "scope": if config: self.current_loadout = self._get_current_config() self.current_loadout.weapon_scope = config self.scope_label.setText(f"{config.name} (+{config.range_bonus}m)") else: if self.current_loadout: self.current_loadout.weapon_scope = None self.scope_label.setText("None") elif attachment_type == "absorber": if config: self.current_loadout = self._get_current_config() self.current_loadout.weapon_absorber = config # Get damage reduction from attachment attachments = get_mock_attachments("absorber") absorber = next((a for a in attachments if a.name == config.name), None) reduction = absorber.damage_reduction if absorber else Decimal("0") self.absorber_label.setText(f"{config.name} (-{reduction} dmg)") else: if self.current_loadout: self.current_loadout.weapon_absorber = None self.absorber_label.setText("None") elif attachment_type == "plating": if config: self.current_loadout = self._get_current_config() self.current_loadout.armor_plating = config # Calculate total protection from plating attachments = get_mock_attachments("plating") plating = next((p for p in attachments if p.name == config.name), None) if plating: total_prot = plating.get_total_protection() self.plating_label.setText(f"{config.name} (+{total_prot} prot)") else: self.plating_label.setText(config.name) else: if self.current_loadout: self.current_loadout.armor_plating = None self.plating_label.setText("None") self._update_calculations() def _on_remove_amp(self): """Remove amplifier.""" if self.current_loadout: self.current_loadout.weapon_amplifier = None self.amp_label.setText("None") self._update_calculations() def _on_remove_scope(self): """Remove scope.""" if self.current_loadout: self.current_loadout.weapon_scope = None self.scope_label.setText("None") self._update_calculations() def _on_remove_absorber(self): """Remove absorber.""" if self.current_loadout: self.current_loadout.weapon_absorber = None self.absorber_label.setText("None") self._update_calculations() def _on_remove_plating(self): """Remove plating.""" if self.current_loadout: self.current_loadout.armor_plating = None self.plating_label.setText("None") self._update_calculations() def _on_heal_changed(self, name: str): """Handle healing selection change.""" if name == "-- Custom --": self.heal_cost_edit.setEnabled(True) self.heal_amount_edit.setEnabled(True) self.heal_cost_edit.clear() self.heal_amount_edit.clear() else: for heal in MOCK_HEALING: if heal["name"] == name: self.heal_cost_edit.set_decimal(heal["cost"]) self.heal_amount_edit.set_decimal(heal["amount"]) break self.heal_cost_edit.setEnabled(False) self.heal_amount_edit.setEnabled(False) self._update_calculations() def _update_calculations(self): """Update all cost and DPP calculations.""" try: config = self._get_current_config() # Update DPP dpp = config.calculate_dpp() self.dpp_label.setText(f"{dpp:.4f}") self.total_dpp_label.setText(f"{dpp:.4f}") # Update cost breakdown weapon_cost = config.calculate_weapon_cost_per_hour() armor_cost = config.calculate_armor_cost_per_hour() heal_cost = config.calculate_heal_cost_per_hour() total_cost = config.calculate_total_cost_per_hour() self.weapon_cost_label.setText(f"{weapon_cost:.0f} PEC/hr") self.armor_cost_label.setText(f"{armor_cost:.0f} PEC/hr") self.heal_cost_label.setText(f"{heal_cost:.0f} PEC/hr") self.total_cost_label.setText(f"{total_cost:.2f} PED/hr") except Exception as e: logger.error(f"Calculation error: {e}") def _calculate_break_even(self): """Calculate and display break-even loot value.""" try: config = self._get_current_config() mob_health = self.mob_health_edit.get_decimal() if mob_health <= 0: QMessageBox.warning(self, "Invalid Input", "Mob health must be greater than 0") return break_even = config.calculate_break_even(mob_health) self.break_even_label.setText( f"Break-even: {break_even:.2f} PED (mob HP: {mob_health})" ) except Exception as e: QMessageBox.critical(self, "Error", f"Calculation failed: {str(e)}") def _get_current_config(self) -> LoadoutConfig: """Get current configuration from UI fields.""" config = LoadoutConfig( name=self.loadout_name_edit.text().strip() or "Unnamed", weapon_name=self.current_weapon.name if self.current_weapon else (self.weapon_name_label.text() if self.weapon_name_label.text() != "No weapon selected" else "-- Custom --"), weapon_id=self.current_weapon.id if self.current_weapon else 0, weapon_damage=self.weapon_damage_edit.get_decimal(), weapon_decay_pec=self.weapon_decay_edit.get_decimal(), weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(), armor_name=self.current_armor.name if self.current_armor else (self.armor_name_label.text() if self.armor_name_label.text() != "No armor selected" else "-- None --"), armor_id=self.current_armor.id if self.current_armor else 0, 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_amount=self.heal_amount_edit.get_decimal(), shots_per_hour=self.shots_per_hour_spin.value(), hits_per_hour=self.hits_per_hour_spin.value(), heals_per_hour=self.heals_per_hour_spin.value(), protection_stab=self.protection_stab_edit.get_decimal(), protection_cut=self.protection_cut_edit.get_decimal(), protection_impact=self.protection_impact_edit.get_decimal(), protection_penetration=self.protection_pen_edit.get_decimal(), protection_shrapnel=self.protection_shrap_edit.get_decimal(), protection_burn=self.protection_burn_edit.get_decimal(), protection_cold=self.protection_cold_edit.get_decimal(), protection_acid=self.protection_acid_edit.get_decimal(), protection_electric=self.protection_elec_edit.get_decimal(), ) # Preserve existing attachments if self.current_loadout: config.weapon_amplifier = self.current_loadout.weapon_amplifier config.weapon_scope = self.current_loadout.weapon_scope config.weapon_absorber = self.current_loadout.weapon_absorber config.armor_plating = self.current_loadout.armor_plating return config def _set_config(self, config: LoadoutConfig): """Set UI fields from configuration.""" self.loadout_name_edit.setText(config.name) self.shots_per_hour_spin.setValue(config.shots_per_hour) self.hits_per_hour_spin.setValue(config.hits_per_hour) self.heals_per_hour_spin.setValue(config.heals_per_hour) # Weapon self.weapon_name_label.setText(config.weapon_name) 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) # Weapon attachments if config.weapon_amplifier: self.amp_label.setText(f"{config.weapon_amplifier.name} (+{config.weapon_amplifier.damage_bonus} dmg)") else: self.amp_label.setText("None") if config.weapon_scope: self.scope_label.setText(f"{config.weapon_scope.name} (+{config.weapon_scope.range_bonus}m)") else: self.scope_label.setText("None") if config.weapon_absorber: self.absorber_label.setText(config.weapon_absorber.name) else: self.absorber_label.setText("None") # Armor self.armor_name_label.setText(config.armor_name) self.armor_decay_edit.set_decimal(config.armor_decay_pec) self.protection_stab_edit.set_decimal(config.protection_stab) self.protection_cut_edit.set_decimal(config.protection_cut) self.protection_impact_edit.set_decimal(config.protection_impact) self.protection_pen_edit.set_decimal(config.protection_penetration) self.protection_shrap_edit.set_decimal(config.protection_shrapnel) self.protection_burn_edit.set_decimal(config.protection_burn) 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) # Armor plating if config.armor_plating: self.plating_label.setText(config.armor_plating.name) else: self.plating_label.setText("None") # Healing self.heal_combo.setCurrentText(config.heal_name) self.heal_cost_edit.set_decimal(config.heal_cost_pec) self.heal_amount_edit.set_decimal(config.heal_amount) # Store config self.current_loadout = config self._update_calculations() def _save_loadout(self): """Save current loadout to file.""" name = self.loadout_name_edit.text().strip() if not name: QMessageBox.warning(self, "Missing Name", "Please enter a loadout name") return # Sanitize filename safe_name = "".join(c for c in name if c.isalnum() or c in "._- ").strip() if not safe_name: safe_name = "unnamed" config = self._get_current_config() config.name = name 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_loadout = config self.loadout_saved.emit(name) self._load_saved_loadouts() QMessageBox.information(self, "Saved", f"Loadout '{name}' saved successfully!") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save: {str(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)) # Build tooltip dpp = config.calculate_dpp() cost = config.calculate_total_cost_per_hour() tooltip = ( f"Weapon: {config.weapon_name}\n" f"Armor: {config.armor_name}\n" f"Total DPP: {dpp:.3f}\n" f"Cost/hr: {cost:.2f} PED" ) if config.weapon_amplifier: tooltip += f"\nAmplifier: {config.weapon_amplifier.name}" item.setToolTip(tooltip) self.saved_list.addItem(item) except Exception as e: logger.error(f"Failed to load {filepath}: {e}") continue except Exception as e: logger.error(f"Failed to list loadouts: {e}") def _load_selected(self): """Load the selected loadout from the list.""" item = self.saved_list.currentItem() if item: self._load_from_item(item) else: QMessageBox.information(self, "No Selection", "Please select a loadout to load") def _load_from_item(self, item: QListWidgetItem): """Load loadout from a list item.""" 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._set_config(config) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load: {str(e)}") def _delete_selected(self): """Delete the 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"Are you sure you want to delete '{name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: os.remove(filepath) self._load_saved_loadouts() QMessageBox.information(self, "Deleted", f"'{name}' deleted successfully") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete: {str(e)}") def _new_loadout(self): """Clear all fields for a new loadout.""" self.loadout_name_edit.clear() self.weapon_name_label.setText("No weapon selected") self.armor_name_label.setText("No armor selected") # Clear 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() self.heal_amount_edit.clear() # Reset attachment labels self.amp_label.setText("None") self.scope_label.setText("None") self.absorber_label.setText("None") self.plating_label.setText("None") # Reset values self.shots_per_hour_spin.setValue(3600) self.hits_per_hour_spin.setValue(720) self.heals_per_hour_spin.setValue(60) self.mob_health_edit.set_decimal(Decimal("100")) # Reset combos self.heal_combo.setCurrentIndex(0) # Clear stored objects self.current_weapon = None self.current_armor = None self.current_loadout = None self._update_calculations() def get_current_loadout(self) -> Optional[LoadoutConfig]: """Get the currently loaded/created loadout.""" return self.current_loadout # ============================================================================ # Main entry point for testing # ============================================================================ def main(): """Run the loadout manager as a standalone application.""" import sys from PyQt6.QtWidgets import QApplication # Setup logging logging.basicConfig(level=logging.INFO) app = QApplication(sys.argv) app.setStyle('Fusion') # Set application-wide font font = QFont("Segoe UI", 10) app.setFont(font) dialog = LoadoutManagerDialog() # Connect signal for testing dialog.loadout_saved.connect(lambda name: print(f"Loadout saved: {name}")) if dialog.exec() == QDialog.DialogCode.Accepted: config = dialog.get_current_loadout() if config: print(f"\nFinal Loadout: {config.name}") print(f" Weapon: {config.weapon_name}") if config.weapon_amplifier: print(f" Amplifier: {config.weapon_amplifier.name}") print(f" Total DPP: {config.calculate_dpp():.4f}") print(f" Total Cost: {config.calculate_total_cost_per_hour():.2f} PED/hr") sys.exit(0) if __name__ == "__main__": main()