Lemontropia-Suite/ui/loadout_manager_simple.py

668 lines
25 KiB
Python

"""
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