From cdc9f5b8256de7766e43a31674a8564935b0fd0b Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Mon, 9 Feb 2026 21:41:55 +0000 Subject: [PATCH] refactor: simplified cost-focused Loadout Manager - New LoadoutManagerSimple with clean cost-focused design - LoadoutConfig now stores only: cost_per_shot/hit/heal + display names - Legacy format support for backward compatibility - Simplified LoadoutSelectionDialog with clear cost preview - Updated MainWindow to use new simplified structure - Removed 3 overlapping armor systems, replaced with single decay value - JSON serialization is now simple and reliable Key principle: Only store what's needed for cost tracking. --- ui/loadout_manager_simple.py | 667 ++++++++++++++++++++++++++ ui/loadout_selection_dialog_simple.py | 262 ++++++++++ ui/main_window.py | 116 ++--- 3 files changed, 989 insertions(+), 56 deletions(-) create mode 100644 ui/loadout_manager_simple.py create mode 100644 ui/loadout_selection_dialog_simple.py diff --git a/ui/loadout_manager_simple.py b/ui/loadout_manager_simple.py new file mode 100644 index 0000000..30c92ee --- /dev/null +++ b/ui/loadout_manager_simple.py @@ -0,0 +1,667 @@ +""" +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)) + 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}") + 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_selection_dialog import ArmorSelectionDialog + + dialog = ArmorSelectionDialog(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 diff --git a/ui/loadout_selection_dialog_simple.py b/ui/loadout_selection_dialog_simple.py new file mode 100644 index 0000000..6f40208 --- /dev/null +++ b/ui/loadout_selection_dialog_simple.py @@ -0,0 +1,262 @@ +""" +Lemontropia Suite - Loadout Selection Dialog v2.0 +Simplified cost-focused selection. +""" + +import json +import logging +from decimal import Decimal +from pathlib import Path + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QListWidget, QListWidgetItem, + QMessageBox, QWidget, QGridLayout +) +from PyQt6.QtCore import Qt, pyqtSignal + +from ui.loadout_manager_simple import LoadoutConfig + +logger = logging.getLogger(__name__) + + +class LoadoutSelectionDialog(QDialog): + """Simplified dialog for selecting a loadout to start a session. + + Emits cost data that can be used directly by MainWindow for tracking. + """ + + # Signal emits: { + # 'id': 0, # 0 = file-based + # 'name': 'Loadout Name', + # 'source': 'file', + # 'costs': { + # 'cost_per_shot': Decimal, + # 'cost_per_hit': Decimal, + # 'cost_per_heal': Decimal, + # }, + # 'display': { + # 'weapon_name': str, + # 'armor_name': str, + # 'healing_name': str, + # } + # } + loadout_selected = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Loadout for Session") + self.setMinimumSize(500, 400) + + self.config_dir = Path.home() / ".lemontropia" / "loadouts" + self.selected_loadout = None + + self._setup_ui() + self._load_loadouts() + + def _setup_ui(self): + """Setup simple UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(10) + + # Header + header = QLabel("Select a loadout to track costs during your session:") + header.setStyleSheet("font-size: 12px; color: #888;") + layout.addWidget(header) + + # Loadout list + self.loadout_list = QListWidget() + self.loadout_list.itemClicked.connect(self._on_select) + self.loadout_list.itemDoubleClicked.connect(self._on_double_click) + layout.addWidget(self.loadout_list) + + # Preview panel + self.preview = QWidget() + preview_layout = QGridLayout(self.preview) + + # Weapon + preview_layout.addWidget(QLabel("⚔️ Weapon:"), 0, 0) + self.preview_weapon = QLabel("-") + preview_layout.addWidget(self.preview_weapon, 0, 1) + + preview_layout.addWidget(QLabel(" Cost/Shot:"), 1, 0) + self.preview_cost_shot = QLabel("-") + self.preview_cost_shot.setStyleSheet("color: #7FFF7F;") + preview_layout.addWidget(self.preview_cost_shot, 1, 1) + + # Armor + preview_layout.addWidget(QLabel("🛡️ Armor:"), 2, 0) + self.preview_armor = QLabel("-") + preview_layout.addWidget(self.preview_armor, 2, 1) + + preview_layout.addWidget(QLabel(" Cost/Hit:"), 3, 0) + self.preview_cost_hit = QLabel("-") + self.preview_cost_hit.setStyleSheet("color: #7FFF7F;") + preview_layout.addWidget(self.preview_cost_hit, 3, 1) + + # Healing + preview_layout.addWidget(QLabel("💚 Healing:"), 4, 0) + self.preview_healing = QLabel("-") + preview_layout.addWidget(self.preview_healing, 4, 1) + + preview_layout.addWidget(QLabel(" Cost/Heal:"), 5, 0) + self.preview_cost_heal = QLabel("-") + self.preview_cost_heal.setStyleSheet("color: #7FFF7F;") + preview_layout.addWidget(self.preview_cost_heal, 5, 1) + + preview_layout.setColumnStretch(1, 1) + layout.addWidget(self.preview) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.skip_btn = QPushButton("Skip (No Cost Tracking)") + self.skip_btn.clicked.connect(self._on_skip) + self.skip_btn.setStyleSheet("color: #888;") + button_layout.addWidget(self.skip_btn) + + self.ok_btn = QPushButton("Start Session ▶") + self.ok_btn.clicked.connect(self._on_accept) + self.ok_btn.setEnabled(False) + self.ok_btn.setStyleSheet(""" + QPushButton { + background-color: #2E7D32; + color: white; + padding: 8px 16px; + font-weight: bold; + } + QPushButton:disabled { + background-color: #333; + color: #666; + } + """) + button_layout.addWidget(self.ok_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(self.cancel_btn) + + layout.addLayout(button_layout) + + def _load_loadouts(self): + """Load all saved loadouts.""" + self.loadout_list.clear() + self.loadouts = [] + + if not self.config_dir.exists(): + return + + 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) + + # Build display text + has_costs = ( + config.weapon_cost_per_shot > 0 or + config.armor_cost_per_hit > 0 or + config.healing_cost_per_heal > 0 + ) + + display = f"📋 {config.name}" + if has_costs: + display += f" (💰 {config.weapon_cost_per_shot:.3f}/shot)" + + item = QListWidgetItem(display) + item.setData(Qt.ItemDataRole.UserRole, config) + + # Tooltip + tooltip = ( + f"Weapon: {config.weapon_name}\n" + f"Armor: {config.armor_name}\n" + f"Healing: {config.healing_name}\n" + f"\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.loadout_list.addItem(item) + self.loadouts.append(config) + except Exception as e: + logger.error(f"Failed to load {filepath}: {e}") + except Exception as e: + logger.error(f"Failed to list loadouts: {e}") + + if not self.loadouts: + item = QListWidgetItem("(No saved loadouts)") + item.setFlags(Qt.ItemFlag.NoItemFlags) + self.loadout_list.addItem(item) + + def _on_select(self, item: QListWidgetItem): + """Update preview when loadout selected.""" + config = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(config, LoadoutConfig): + return + + self.selected_loadout = config + + # Update preview + self.preview_weapon.setText(config.weapon_name if config.weapon_name != "None" else "Not set") + self.preview_armor.setText(config.armor_name if config.armor_name != "None" else "Not set") + self.preview_healing.setText(config.healing_name if config.healing_name != "None" else "Not set") + + self.preview_cost_shot.setText(f"{config.weapon_cost_per_shot:.4f} PED") + self.preview_cost_hit.setText(f"{config.armor_cost_per_hit:.4f} PED") + self.preview_cost_heal.setText(f"{config.healing_cost_per_heal:.4f} PED") + + self.ok_btn.setEnabled(True) + + def _on_double_click(self, item: QListWidgetItem): + """Double-click to select immediately.""" + self._on_select(item) + self._on_accept() + + def _on_skip(self): + """Start session without cost tracking.""" + self.loadout_selected.emit({ + 'id': 0, + 'name': 'No Loadout', + 'source': 'none', + 'costs': { + 'cost_per_shot': Decimal('0'), + 'cost_per_hit': Decimal('0'), + 'cost_per_heal': Decimal('0'), + }, + 'display': { + 'weapon_name': 'None', + 'armor_name': 'None', + 'healing_name': 'None', + } + }) + self.accept() + + def _on_accept(self): + """Emit selected loadout and close.""" + if not self.selected_loadout: + QMessageBox.warning(self, "No Selection", "Please select a loadout") + return + + config = self.selected_loadout + + self.loadout_selected.emit({ + 'id': 0, # File-based + 'name': config.name, + 'source': 'file', + 'costs': { + 'cost_per_shot': config.weapon_cost_per_shot, + 'cost_per_hit': config.armor_cost_per_hit, + 'cost_per_heal': config.healing_cost_per_heal, + }, + 'display': { + 'weapon_name': config.weapon_name, + 'armor_name': config.armor_name, + 'healing_name': config.healing_name, + } + }) + + self.accept() diff --git a/ui/main_window.py b/ui/main_window.py index e3e1a5f..6c0f46c 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -936,23 +936,23 @@ class MainWindow(QMainWindow): # Show HUD and start session tracking self.hud.show() - # Get gear names from loadout selection if available, otherwise fall back to selected weapon - weapon_name = getattr(self, '_session_weapon_name', None) or self._selected_weapon or "Unknown" - armor_name = getattr(self, '_session_armor_name', None) or "None" - healing_name = getattr(self, '_session_healing_name', None) or "None" + # Get gear names and costs from simplified loadout structure + session_display = getattr(self, '_session_display', {}) + session_costs = getattr(self, '_session_costs', {}) + + weapon_name = session_display.get('weapon_name', self._selected_weapon or "Unknown") + armor_name = session_display.get('armor_name', "None") + healing_name = session_display.get('healing_name', "None") + loadout_name = "Loadout" if session_costs else "Default" weapon_stats = self._selected_weapon_stats or {} weapon_dpp = Decimal(str(weapon_stats.get('dpp', 0))) weapon_cost_per_hour = Decimal(str(weapon_stats.get('cost_per_hour', 0))) - # Get loadout name from session selection - loadout_info = getattr(self, '_session_loadout_info', None) - loadout_name = loadout_info.get('name', 'Default') if loadout_info else "Default" - - # Get cost data from session loadout - cost_per_shot = getattr(self, '_session_cost_per_shot', Decimal('0')) - cost_per_hit = getattr(self, '_session_cost_per_hit', Decimal('0')) - cost_per_heal = getattr(self, '_session_cost_per_heal', Decimal('0')) + # Get cost data from simplified structure + cost_per_shot = session_costs.get('cost_per_shot', Decimal('0')) + cost_per_hit = session_costs.get('cost_per_hit', Decimal('0')) + cost_per_heal = session_costs.get('cost_per_heal', Decimal('0')) self.hud.start_session( weapon=weapon_name, @@ -966,12 +966,8 @@ class MainWindow(QMainWindow): cost_per_heal=cost_per_heal ) - # Set up cost tracker if loadout selected (database-based only) - if loadout_info and loadout_info.get('id') and loadout_info.get('source') == 'db': - self._setup_session_cost_tracker(loadout_info) - else: - # For JSON-based loadouts, use manual cost tracking based on extracted values - self.log_info("CostTracker", "Using manual cost tracking for file-based loadout") + # Simple cost tracking - no database required + self.log_info("CostTracker", "Cost tracking enabled with pre-calculated values") self.log_info("HUD", f"HUD shown - Weapon: {weapon_name}, Armor: {armor_name}, Healing: {healing_name}, Loadout: {loadout_name}") @@ -1239,45 +1235,41 @@ class MainWindow(QMainWindow): """Handle start session button - shows loadout selection first.""" if self.current_project and self.session_state == SessionState.IDLE: # Show loadout selection dialog - from ui.loadout_selection_dialog import LoadoutSelectionDialog + from ui.loadout_selection_dialog_simple import LoadoutSelectionDialog dialog = LoadoutSelectionDialog(self) dialog.loadout_selected.connect(self._on_loadout_selected_for_session) dialog.rejected.connect(lambda: self.log_info("Session", "Session start cancelled - no loadout selected")) dialog.exec() def _on_loadout_selected_for_session(self, loadout_info: dict): - """Handle loadout selection and start session.""" - loadout_id = loadout_info.get('id', 0) + """Handle loadout selection and start session - simplified cost-focused version.""" loadout_name = loadout_info.get('name', 'No Loadout') - loadout_data = loadout_info.get('data', {}) + costs = loadout_info.get('costs', {}) + display = loadout_info.get('display', {}) - if loadout_id > 0: - self.log_info("Session", f"Starting session with loadout: {loadout_name} (ID: {loadout_id})") - # Store the selected loadout info for use in start_session - self._session_loadout_info = loadout_info - - # Extract gear names from loadout data - self._session_weapon_name = loadout_data.get('weapon_name', 'Unknown') - self._session_armor_name = loadout_data.get('armor_set_name', 'Unknown') - self._session_healing_name = loadout_data.get('heal_name', 'Unknown') - - # Extract cost data for session tracking (even for JSON-based loadouts) - from decimal import Decimal - self._session_cost_per_shot = Decimal(str(loadout_data.get('weapon_decay_pec', 0))) / Decimal('100') + \ - Decimal(str(loadout_data.get('weapon_ammo_pec', 0))) * Decimal('0.0001') - self._session_cost_per_hit = Decimal(str(loadout_data.get('armor_decay_pec', 0))) / Decimal('100') - self._session_cost_per_heal = Decimal(str(loadout_data.get('heal_cost_pec', 0))) / Decimal('100') - - self.log_info("SessionCosts", f"Cost/Shot: {self._session_cost_per_shot:.4f}, Cost/Hit: {self._session_cost_per_hit:.4f}, Cost/Heal: {self._session_cost_per_heal:.4f}") + # Store cost data for session tracking + from decimal import Decimal + self._session_costs = { + 'cost_per_shot': costs.get('cost_per_shot', Decimal('0')), + 'cost_per_hit': costs.get('cost_per_hit', Decimal('0')), + 'cost_per_heal': costs.get('cost_per_heal', Decimal('0')), + } + + # Store display data for HUD + self._session_display = { + 'weapon_name': display.get('weapon_name', 'None'), + 'armor_name': display.get('armor_name', 'None'), + 'healing_name': display.get('healing_name', 'None'), + } + + if any(self._session_costs.values()): + self.log_info("Session", f"Starting with loadout: {loadout_name}") + self.log_info("SessionCosts", + f"Shot: {self._session_costs['cost_per_shot']:.4f} PED, " + f"Hit: {self._session_costs['cost_per_hit']:.4f} PED, " + f"Heal: {self._session_costs['cost_per_heal']:.4f} PED") else: - self.log_info("Session", "Starting session without loadout") - self._session_loadout_info = None - self._session_weapon_name = None - self._session_armor_name = None - self._session_healing_name = None - self._session_cost_per_shot = None - self._session_cost_per_hit = None - self._session_cost_per_heal = None + self.log_info("Session", f"Starting with loadout: {loadout_name} (no costs configured)") # Now start the session if self.current_project: @@ -1462,25 +1454,37 @@ class MainWindow(QMainWindow): def on_loadout_manager(self): """Open Loadout Manager dialog.""" - from ui.loadout_manager import LoadoutManagerDialog + from ui.loadout_manager_simple import LoadoutManagerDialog dialog = LoadoutManagerDialog(self) dialog.loadout_saved.connect(self.on_loadout_selected) dialog.exec() def on_loadout_selected(self, loadout): - """Handle loadout selection from Loadout Manager.""" + """Handle loadout selection from Loadout Manager - simplified version.""" self._selected_loadout = loadout self.log_info("Loadout", f"Selected loadout: {loadout.name}") - # Update selected gear from loadout + # Update selected gear from loadout (simplified structure) if hasattr(loadout, 'weapon_name'): self._selected_weapon = loadout.weapon_name - if hasattr(loadout, 'heal_cost_pec'): - # Create medical tool stats from loadout heal cost - self._selected_medical_tool = loadout.heal_name + if hasattr(loadout, 'healing_name'): + self._selected_medical_tool = loadout.healing_name self._selected_medical_tool_stats = { - 'decay': float(loadout.heal_cost_pec), - 'cost_per_heal': float(loadout.heal_cost_pec) / 100.0, # Convert PEC to PED + 'decay': float(loadout.healing_decay_pec), + 'cost_per_heal': float(loadout.healing_cost_per_heal), + } + # Store simplified costs for session + if hasattr(loadout, 'weapon_cost_per_shot'): + self._session_costs = { + 'cost_per_shot': loadout.weapon_cost_per_shot, + 'cost_per_hit': loadout.armor_cost_per_hit, + 'cost_per_heal': loadout.healing_cost_per_heal, + } + if hasattr(loadout, 'weapon_name'): + self._session_display = { + 'weapon_name': loadout.weapon_name, + 'armor_name': loadout.armor_name, + 'healing_name': loadout.healing_name, } def on_select_gear(self, gear_type: str = "weapon"):