Lemontropia-Suite/ui/loadout_manager.py

1802 lines
71 KiB
Python

"""
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"<b>{attachment.name}</b><br>"
text += f"Decay: {attachment.decay_pec} PEC<br>"
if isinstance(attachment, WeaponAmplifier):
text += f"Damage Increase: +{attachment.damage_increase}<br>"
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()