Lemontropia-Suite/ui/loadout_manager.py

1979 lines
76 KiB
Python

"""
Lemontropia Suite - Loadout Manager UI v3.0
Complete armor system with sets, individual pieces, and plating.
"""
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,
QStackedWidget, QSizePolicy
)
from PyQt6.QtCore import Qt, pyqtSignal, QThread
from PyQt6.QtGui import QFont
from core.nexus_api import EntropiaNexusAPI, WeaponStats, ArmorStats, FinderStats
from core.nexus_full_api import get_nexus_api, NexusArmor, NexusHealingTool
from core.attachments import (
Attachment, WeaponAmplifier, WeaponScope, WeaponAbsorber,
ArmorPlating, Enhancer, can_attach, get_mock_attachments
)
from core.armor_system import (
ArmorSlot, ArmorSet, ArmorPiece, ArmorPlate, EquippedArmor,
ProtectionProfile, HitResult, calculate_hit_protection,
get_all_armor_sets, get_all_armor_pieces, get_pieces_by_slot,
get_mock_plates, format_protection, ALL_ARMOR_SLOTS,
create_ghost_set, create_shogun_set, create_vigilante_set,
create_hermes_set, create_pixie_set,
)
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
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 armor system."""
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 System
equipped_armor: Optional[EquippedArmor] = None
armor_set_name: str = "-- None --"
# Legacy armor fields for backward compatibility
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")
# 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
heals_per_hour: int = 60
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."""
total = self.weapon_ammo_pec * Decimal("0.01")
if self.weapon_amplifier:
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 using the equipped armor system."""
if self.equipped_armor:
return self.equipped_armor.get_total_decay_per_hit() * Decimal(self.hits_per_hour)
# Legacy fallback
return self.armor_decay_pec * 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."""
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")
def calculate_break_even(self, mob_health: Decimal) -> Decimal:
"""Calculate break-even loot value for a mob."""
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")
def get_total_protection(self) -> ProtectionProfile:
"""Get total protection from equipped armor."""
if self.equipped_armor:
return self.equipped_armor.get_total_protection()
# Legacy fallback
return ProtectionProfile(
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,
)
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()
# Handle equipped armor
if self.equipped_armor:
data['equipped_armor'] = self.equipped_armor.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
# Handle equipped armor
if 'equipped_armor' in data and data['equipped_armor']:
data['equipped_armor'] = EquippedArmor.from_dict(data['equipped_armor'])
else:
data['equipped_armor'] = None
# Handle legacy configs
if 'heal_name' not in data:
data['heal_name'] = '-- Custom --'
if 'armor_set_name' not in data:
data['armor_set_name'] = '-- None --'
return cls(**data)
# ============================================================================
# Healing Tools Data - Using real database
# ============================================================================
def get_healing_tools_data():
"""Get healing tools from the real database."""
try:
from core.healing_tools import HEALING_TOOLS
return [
{
"name": tool.name,
"cost": tool.decay_pec,
"amount": tool.heal_amount,
"is_chip": tool.is_chip
}
for tool in HEALING_TOOLS
]
except ImportError:
# Fallback to mock data if import fails
return [
{"name": "Vivo T10", "cost": Decimal("0.815"), "amount": Decimal("10")},
{"name": "Vivo S10", "cost": Decimal("1.705"), "amount": Decimal("21")},
{"name": "Hedoc MM10", "cost": Decimal("2.09"), "amount": Decimal("44")},
{"name": "Adjusted Restoration Chip", "cost": Decimal("2.88"), "amount": Decimal("60")},
{"name": "Restoration Chip IV (L)", "cost": Decimal("2.8"), "amount": Decimal("45")},
]
# Legacy mock data for compatibility
MOCK_HEALING = get_healing_tools_data()
# ============================================================================
# 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;
}
""")
class ArmorSlotWidget(QWidget):
"""Widget for configuring a single armor slot with piece and plate."""
piece_changed = pyqtSignal()
plate_changed = pyqtSignal()
def __init__(self, slot: ArmorSlot, parent=None):
super().__init__(parent)
self.slot = slot
self.current_piece: Optional[ArmorPiece] = None
self.current_plate: Optional[ArmorPlate] = None
self._setup_ui()
def _setup_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(5, 2, 5, 2)
layout.setSpacing(10)
slot_name = self._get_slot_display_name()
# Slot label
self.slot_label = QLabel(f"<b>{slot_name}:</b>")
self.slot_label.setFixedWidth(100)
layout.addWidget(self.slot_label)
# Armor piece selector
self.piece_combo = QComboBox()
self.piece_combo.setMinimumWidth(180)
self.piece_combo.currentTextChanged.connect(self._on_piece_changed)
layout.addWidget(self.piece_combo)
# Protection display
self.protection_label = QLabel("-")
self.protection_label.setStyleSheet("color: #888888; font-size: 11px;")
self.protection_label.setFixedWidth(120)
layout.addWidget(self.protection_label)
# Plate selector
self.plate_combo = QComboBox()
self.plate_combo.setMinimumWidth(150)
self.plate_combo.currentTextChanged.connect(self._on_plate_changed)
layout.addWidget(self.plate_combo)
# Total protection
self.total_label = QLabel("Total: 0")
self.total_label.setStyleSheet("color: #4caf50; font-weight: bold;")
self.total_label.setFixedWidth(80)
layout.addWidget(self.total_label)
layout.addStretch()
# Populate combos
self._populate_pieces()
self._populate_plates()
def _get_slot_display_name(self) -> str:
"""Get human-readable slot name (matches Entropia Nexus)."""
names = {
ArmorSlot.HEAD: "Head",
ArmorSlot.TORSO: "Torso",
ArmorSlot.ARMS: "Arms",
ArmorSlot.HANDS: "Hands",
ArmorSlot.LEGS: "Legs",
ArmorSlot.SHINS: "Shins",
ArmorSlot.FEET: "Feet",
}
return names.get(self.slot, self.slot.value)
def _populate_pieces(self):
"""Populate armor piece combo."""
self.piece_combo.clear()
self.piece_combo.addItem("-- Empty --")
# Get pieces for this slot
pieces = get_pieces_by_slot(self.slot)
for piece in pieces:
display = f"{piece.name} ({piece.set_name})"
self.piece_combo.addItem(display, piece)
def _populate_plates(self):
"""Populate plate combo."""
self.plate_combo.clear()
self.plate_combo.addItem("-- No Plate --")
plates = get_mock_plates()
for plate in plates:
display = f"{plate.name} (+{plate.get_total_protection()})"
self.plate_combo.addItem(display, plate)
def _on_piece_changed(self, text: str):
"""Handle armor piece selection."""
if text == "-- Empty --":
self.current_piece = None
self.protection_label.setText("-")
else:
self.current_piece = self.piece_combo.currentData()
if self.current_piece:
prot = format_protection(self.current_piece.protection)
self.protection_label.setText(prot)
self._update_total()
self.piece_changed.emit()
def _on_plate_changed(self, text: str):
"""Handle plate selection."""
if text == "-- No Plate --":
self.current_plate = None
else:
self.current_plate = self.plate_combo.currentData()
self._update_total()
self.plate_changed.emit()
def _update_total(self):
"""Update total protection display."""
total = Decimal("0")
if self.current_piece:
total += self.current_piece.protection.get_total()
if self.current_plate:
total += self.current_plate.get_total_protection()
self.total_label.setText(f"Total: {total}")
def get_piece(self) -> Optional[ArmorPiece]:
"""Get selected armor piece."""
return self.current_piece
def get_plate(self) -> Optional[ArmorPlate]:
"""Get selected plate."""
return self.current_plate
def set_piece(self, piece: Optional[ArmorPiece]):
"""Set selected armor piece."""
if piece is None:
self.piece_combo.setCurrentIndex(0)
return
# Find and select the piece
for i in range(self.piece_combo.count()):
data = self.piece_combo.itemData(i)
if data and data.item_id == piece.item_id:
self.piece_combo.setCurrentIndex(i)
return
self.piece_combo.setCurrentIndex(0)
def set_plate(self, plate: Optional[ArmorPlate]):
"""Set selected plate."""
if plate is None:
self.plate_combo.setCurrentIndex(0)
return
# Find and select the plate
for i in range(self.plate_combo.count()):
data = self.plate_combo.itemData(i)
if data and data.item_id == plate.item_id:
self.plate_combo.setCurrentIndex(i)
return
self.plate_combo.setCurrentIndex(0)
def get_total_protection(self) -> ProtectionProfile:
"""Get total protection for this slot."""
total = ProtectionProfile()
if self.current_piece:
total = total.add(self.current_piece.protection)
if self.current_plate:
total = total.add(self.current_plate.protection)
return total
def get_total_decay(self) -> Decimal:
"""Get total decay per hit for this slot (estimated)."""
# Estimate based on typical hit of 10 hp
typical_hit = Decimal("10")
decay = Decimal("0")
if self.current_piece:
# Armor only decays for damage it actually absorbs
armor_absorb = min(typical_hit, self.current_piece.protection.get_total())
decay += self.current_piece.get_decay_for_damage(armor_absorb)
if self.current_plate:
# Plate only decays for damage it actually absorbs
plate_absorb = min(typical_hit, self.current_plate.get_total_protection())
decay += self.current_plate.get_decay_for_damage(plate_absorb)
return decay
# ============================================================================
# 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)
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)
self.status_label = QLabel("Loading weapons from Entropia Nexus...")
layout.addWidget(self.status_label)
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)
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)
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)
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])
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)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Select Armor - Entropia Nexus")
self.setMinimumSize(900, 600)
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)
self.status_label = QLabel("Loading armors from Entropia Nexus...")
layout.addWidget(self.status_label)
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)
self.results_tree = QTreeWidget()
self.results_tree.setHeaderLabels([
"Name", "Type", "Durability", "Impact", "Cut", "Stab", "Burn", "Cold"
])
header = self.results_tree.header()
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
header.setStretchLastSection(False)
self.results_tree.itemSelectionChanged.connect(self._on_selection_changed)
self.results_tree.itemDoubleClicked.connect(self._on_double_click)
layout.addWidget(self.results_tree)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _load_data(self):
"""Load armors from API."""
try:
self.armors = self.api.get_all_armors()
self._populate_results(self.armors)
self.status_label.setText(f"Loaded {len(self.armors)} armors from Entropia Nexus")
except Exception as e:
self.status_label.setText(f"Error loading armors: {e}")
def _populate_results(self, armors):
"""Populate results tree."""
self.results_tree.clear()
for armor in armors:
item = QTreeWidgetItem()
item.setText(0, armor.name)
item.setText(1, armor.type or "Unknown")
item.setText(2, str(armor.durability))
item.setText(3, str(armor.protection_impact))
item.setText(4, str(armor.protection_cut))
item.setText(5, str(armor.protection_stab))
item.setText(6, str(armor.protection_burn))
item.setText(7, str(armor.protection_cold))
item.setData(0, Qt.ItemDataRole.UserRole, armor)
self.results_tree.addTopLevelItem(item)
def _on_search(self):
"""Handle search."""
query = self.search_input.text().lower()
if not query:
self._populate_results(self.armors)
return
filtered = [a for a in self.armors if query in a.name.lower()]
self._populate_results(filtered)
self.status_label.setText(f"Found {len(filtered)} armors matching '{query}'")
def _on_selection_changed(self):
"""Handle selection change."""
items = self.results_tree.selectedItems()
if items:
self.selected_armor = items[0].data(0, Qt.ItemDataRole.UserRole)
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 armor system."""
loadout_saved = pyqtSignal(str)
def __init__(self, parent=None, config_dir: Optional[str] = None):
super().__init__(parent)
self.setWindowTitle("Lemontropia Suite - Loadout Manager v3.0")
self.setMinimumSize(1100, 900)
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_set: Optional[ArmorSet] = None
self.equipped_armor: Optional[EquippedArmor] = None
# Armor slot widgets
self.slot_widgets: Dict[ArmorSlot, ArmorSlotWidget] = {}
self._apply_dark_theme()
self._create_widgets()
self._create_layout()
self._connect_signals()
self._load_saved_loadouts()
self._populate_armor_sets()
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;
}
QPushButton#clearButton {
background-color: #5d4037;
border-color: #8d6e63;
}
QPushButton#clearButton:hover {
background-color: #8d6e63;
}
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 - NEW COMPLETE SYSTEM
self.armor_group = DarkGroupBox("🛡️ Armor Configuration")
# Armor set selector
self.armor_set_combo = QComboBox()
self.armor_set_combo.setMinimumWidth(250)
self.equip_set_btn = QPushButton("Equip Full Set")
self.equip_set_btn.setObjectName("selectButton")
self.clear_armor_btn = QPushButton("Clear All")
self.clear_armor_btn.setObjectName("clearButton")
# Armor protection summary
self.armor_summary_label = QLabel("No armor equipped")
self.armor_summary_label.setStyleSheet("color: #888888; padding: 5px;")
# Create slot widgets
for slot in ALL_ARMOR_SLOTS:
self.slot_widgets[slot] = ArmorSlotWidget(slot)
self.slot_widgets[slot].piece_changed.connect(self._on_armor_changed)
self.slot_widgets[slot].plate_changed.connect(self._on_armor_changed)
# 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;")
# Protection summary
self.protection_summary_label = QLabel("No protection")
self.protection_summary_label.setStyleSheet("color: #4a90d9; font-size: 12px;")
# 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 - COMPLETE SYSTEM
armor_layout = QVBoxLayout(self.armor_group)
# Armor set selection row
set_layout = QHBoxLayout()
set_layout.addWidget(QLabel("<b>Armor Set:</b>"))
set_layout.addWidget(self.armor_set_combo, stretch=1)
set_layout.addWidget(self.equip_set_btn)
set_layout.addWidget(self.clear_armor_btn)
armor_layout.addLayout(set_layout)
# Add API armor selector button
self.select_armor_api_btn = QPushButton("🔍 Search Entropia Nexus Armors")
self.select_armor_api_btn.setObjectName("selectButton")
self.select_armor_api_btn.clicked.connect(self._on_select_armor_from_api)
armor_layout.addWidget(self.select_armor_api_btn)
# Armor summary
armor_layout.addWidget(self.armor_summary_label)
# Separator
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("background-color: #3d3d3d;")
separator.setFixedHeight(2)
armor_layout.addWidget(separator)
# Individual slot widgets
slots_label = QLabel("<b>Individual Pieces & Plates:</b>")
slots_label.setStyleSheet("padding-top: 10px;")
armor_layout.addWidget(slots_label)
for slot in ALL_ARMOR_SLOTS:
armor_layout.addWidget(self.slot_widgets[slot])
right_layout.addWidget(self.armor_group)
# Healing configuration
heal_layout = QFormLayout(self.heal_group)
# Add healing tool search button
self.select_healing_api_btn = QPushButton("🔍 Search Healing Tools from Nexus")
self.select_healing_api_btn.setObjectName("selectButton")
self.select_healing_api_btn.clicked.connect(self._on_select_healing_from_api)
heal_layout.addRow(self.select_healing_api_btn)
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)
# Protection summary
summary_layout.addRow("Protection:", self.protection_summary_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, 850])
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)
# 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.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)
# Armor
self.equip_set_btn.clicked.connect(self._on_equip_full_set)
self.clear_armor_btn.clicked.connect(self._on_clear_armor)
# 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_armor_sets(self):
"""Populate armor set combo."""
self.armor_set_combo.clear()
self.armor_set_combo.addItem("-- Select a Set --")
sets = get_all_armor_sets()
for armor_set in sets:
total_prot = armor_set.get_total_protection().get_total()
display = f"{armor_set.name} (Prot: {total_prot})"
self.armor_set_combo.addItem(display, armor_set)
def _populate_healing_data(self):
"""Populate healing combo with real data from database."""
self.heal_combo.clear()
self.heal_combo.addItem("-- Custom --")
# Get real healing tools
healing_tools = get_healing_tools_data()
# Sort by category (chips last)
medical_tools = [h for h in healing_tools if not h.get("is_chip", False)]
chips = [h for h in healing_tools if h.get("is_chip", False)]
# Add medical tools first
if medical_tools:
self.heal_combo.addItem("--- Medical Tools ---")
for tool in medical_tools:
self.heal_combo.addItem(tool["name"])
# Add restoration chips
if chips:
self.heal_combo.addItem("--- Restoration Chips ---")
for chip in sorted(chips, key=lambda x: x["amount"]):
self.heal_combo.addItem(chip["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_from_api(self):
"""Open armor selector dialog from Nexus API."""
from ui.armor_selector import ArmorSelectorDialog
dialog = ArmorSelectorDialog(self)
dialog.armor_selected.connect(self._on_api_armor_selected)
dialog.exec()
def _on_api_armor_selected(self, armor: NexusArmor):
"""Handle armor selection from API."""
# Store selected armor info
self._selected_api_armor = armor
QMessageBox.information(
self,
"Armor Selected",
f"Selected: {armor.name}\n"
f"Durability: {armor.durability}\n"
f"Protection: Impact {armor.protection_impact}, Cut {armor.protection_cut}, Stab {armor.protection_stab}"
)
def _on_select_healing_from_api(self):
"""Open healing tool selector dialog from Nexus API."""
from ui.healing_selector import HealingSelectorDialog
dialog = HealingSelectorDialog(self)
dialog.tool_selected.connect(self._on_api_healing_selected)
dialog.exec()
def _on_api_healing_selected(self, tool: NexusHealingTool):
"""Handle healing tool selection from API."""
self._selected_api_healing = tool
# Update the healing combo to show selected tool
# Find or add the tool to combo
index = self.heal_combo.findText(tool.name)
if index < 0:
self.heal_combo.addItem(tool.name)
index = self.heal_combo.count() - 1
self.heal_combo.setCurrentIndex(index)
# Update cost and amount fields
self.heal_cost_edit.setText(str(tool.decay))
self.heal_amount_edit.setText(str(tool.heal_amount))
self._update_calculations()
QMessageBox.information(
self,
"Healing Tool Selected",
f"Selected: {tool.name}\n"
f"Heal: {tool.heal_amount} HP\n"
f"Decay: {tool.decay:.2f} PEC ({tool.heal_per_pec:.2f} hp/pec)"
)
def _on_attach(self, attachment_type: str):
"""Handle attachment selection."""
from core.attachments import get_mock_attachments
attachments = get_mock_attachments(attachment_type)
if not attachments:
QMessageBox.information(self, "No Attachments", f"No {attachment_type} attachments available.")
return
# Create simple selection dialog
dialog = QDialog(self)
dialog.setWindowTitle(f"Select {attachment_type.title()}")
dialog.setMinimumWidth(400)
layout = QVBoxLayout(dialog)
list_widget = QListWidget()
for att in attachments:
item = QListWidgetItem(f"📎 {att.name}")
item.setData(Qt.ItemDataRole.UserRole, att)
list_widget.addItem(item)
layout.addWidget(list_widget)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
# Add None option
none_btn = QPushButton("Remove Attachment")
none_btn.clicked.connect(lambda: self._clear_attachment(attachment_type) or dialog.reject())
layout.addWidget(none_btn)
if dialog.exec() == QDialog.DialogCode.Accepted:
selected = list_widget.currentItem()
if selected:
att = selected.data(Qt.ItemDataRole.UserRole)
self._apply_attachment(attachment_type, att)
def _apply_attachment(self, attachment_type: str, att):
"""Apply selected attachment."""
if attachment_type == "amplifier":
self.amp_label.setText(f"{att.name} (+{att.damage_increase} dmg)")
elif attachment_type == "scope":
self.scope_label.setText(f"{att.name} (+{att.range_increase}m)")
elif attachment_type == "absorber":
self.absorber_label.setText(f"{att.name} (-{att.damage_reduction} dmg)")
self._update_calculations()
def _clear_attachment(self, attachment_type: str):
"""Clear an attachment."""
if attachment_type == "amplifier":
self.amp_label.setText("None")
elif attachment_type == "scope":
self.scope_label.setText("None")
elif attachment_type == "absorber":
self.absorber_label.setText("None")
self._update_calculations()
def _on_remove_amp(self):
"""Remove amplifier."""
self.amp_label.setText("None")
self._update_calculations()
def _on_remove_scope(self):
"""Remove scope."""
self.scope_label.setText("None")
self._update_calculations()
def _on_remove_absorber(self):
"""Remove absorber."""
self.absorber_label.setText("None")
self._update_calculations()
def _on_equip_full_set(self):
"""Equip a full armor set."""
if self.armor_set_combo.currentIndex() <= 0:
QMessageBox.information(self, "No Selection", "Please select an armor set first.")
return
armor_set = self.armor_set_combo.currentData()
if not armor_set:
return
# Clear any individual pieces
for widget in self.slot_widgets.values():
widget.set_piece(None)
widget.set_plate(None)
# Equip set pieces
for slot, piece in armor_set.pieces.items():
if slot in self.slot_widgets:
self.slot_widgets[slot].set_piece(piece)
self.current_armor_set = armor_set
self._update_armor_summary()
self._update_calculations()
QMessageBox.information(self, "Set Equipped", f"Equipped {armor_set.name}")
def _on_clear_armor(self):
"""Clear all armor."""
for widget in self.slot_widgets.values():
widget.set_piece(None)
widget.set_plate(None)
self.current_armor_set = None
self.armor_set_combo.setCurrentIndex(0)
self._update_armor_summary()
self._update_calculations()
def _on_armor_changed(self):
"""Handle armor piece or plate change."""
# If individual pieces are changed, we're no longer using a pure full set
if self.current_armor_set:
# Check if all pieces match the set
all_match = True
for slot, piece in self.current_armor_set.pieces.items():
widget = self.slot_widgets.get(slot)
if widget:
current = widget.get_piece()
if not current or current.item_id != piece.item_id:
all_match = False
break
if not all_match:
self.current_armor_set = None
self._update_armor_summary()
self._update_calculations()
def _update_armor_summary(self):
"""Update armor summary display."""
equipped_count = 0
for widget in self.slot_widgets.values():
if widget.get_piece():
equipped_count += 1
if equipped_count == 0:
self.armor_summary_label.setText("No armor equipped")
self.armor_summary_label.setStyleSheet("color: #888888; padding: 5px;")
elif equipped_count == 7:
if self.current_armor_set:
self.armor_summary_label.setText(f"✓ Full Set: {self.current_armor_set.name}")
self.armor_summary_label.setStyleSheet("color: #4caf50; font-weight: bold; padding: 5px;")
else:
self.armor_summary_label.setText(f"✓ 7/7 pieces equipped (Mixed Set)")
self.armor_summary_label.setStyleSheet("color: #4caf50; padding: 5px;")
else:
self.armor_summary_label.setText(f"{equipped_count}/7 pieces equipped")
self.armor_summary_label.setStyleSheet("color: #ff9800; padding: 5px;")
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")
# Update protection summary
protection = config.get_total_protection()
prot_text = format_protection(protection)
if prot_text == "None":
self.protection_summary_label.setText("No protection")
else:
self.protection_summary_label.setText(f"Total: {protection.get_total()} | {prot_text}")
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."""
# Build equipped armor from slot widgets
equipped = EquippedArmor()
for slot, widget in self.slot_widgets.items():
piece = widget.get_piece()
if piece:
# Create a copy
piece_copy = ArmorPiece(
name=piece.name,
item_id=piece.item_id,
slot=piece.slot,
set_name=piece.set_name,
decay_per_hit=piece.decay_per_hit,
protection=ProtectionProfile(
stab=piece.protection.stab,
cut=piece.protection.cut,
impact=piece.protection.impact,
penetration=piece.protection.penetration,
shrapnel=piece.protection.shrapnel,
burn=piece.protection.burn,
cold=piece.protection.cold,
acid=piece.protection.acid,
electric=piece.protection.electric,
),
durability=piece.durability,
weight=piece.weight,
)
# Attach plate if selected
plate = widget.get_plate()
if plate:
plate_copy = ArmorPlate(
name=plate.name,
item_id=plate.item_id,
decay_per_hit=plate.decay_per_hit,
protection=ProtectionProfile(
stab=plate.protection.stab,
cut=plate.protection.cut,
impact=plate.protection.impact,
penetration=plate.protection.penetration,
shrapnel=plate.protection.shrapnel,
burn=plate.protection.burn,
cold=plate.protection.cold,
acid=plate.protection.acid,
electric=plate.protection.electric,
),
durability=plate.durability,
)
piece_copy.attach_plate(plate_copy)
equipped.equip_piece(piece_copy)
# Set full set if all pieces match
if self.current_armor_set:
equipped.equip_full_set(self.current_armor_set)
return 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(),
equipped_armor=equipped if equipped.get_all_pieces() else None,
armor_set_name=self.current_armor_set.name if self.current_armor_set else "-- Mixed --",
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(),
)
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 (simplified - just labels)
self.amp_label.setText("None")
self.scope_label.setText("None")
self.absorber_label.setText("None")
# Armor - use equipped_armor if available
if config.equipped_armor:
self.equipped_armor = config.equipped_armor
pieces = config.equipped_armor.get_all_pieces()
for slot, widget in self.slot_widgets.items():
piece = pieces.get(slot)
widget.set_piece(piece)
if piece and piece.attached_plate:
widget.set_plate(piece.attached_plate)
else:
widget.set_plate(None)
# Check if it's a full set
if config.equipped_armor.full_set:
self.current_armor_set = config.equipped_armor.full_set
# Select in combo
for i in range(self.armor_set_combo.count()):
data = self.armor_set_combo.itemData(i)
if data and data.set_id == self.current_armor_set.set_id:
self.armor_set_combo.setCurrentIndex(i)
break
else:
self.current_armor_set = None
self.armor_set_combo.setCurrentIndex(0)
else:
# Legacy or empty
self._on_clear_armor()
self._update_armor_summary()
# 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
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_set_name}\n"
f"Total DPP: {dpp:.3f}\n"
f"Cost/hr: {cost:.2f} PED"
)
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")
# Clear weapon
self.weapon_damage_edit.clear()
self.weapon_decay_edit.clear()
self.weapon_ammo_edit.clear()
# Clear attachments
self.amp_label.setText("None")
self.scope_label.setText("None")
self.absorber_label.setText("None")
# Clear armor
self._on_clear_armor()
# Clear healing
self.heal_cost_edit.clear()
self.heal_amount_edit.clear()
# 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_set = 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
# 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}")
print(f" Armor: {config.armor_set_name}")
if config.equipped_armor:
pieces = config.equipped_armor.get_all_pieces()
print(f" Armor Pieces: {len(pieces)}/7")
print(f" Total DPP: {config.calculate_dpp():.4f}")
print(f" Total Cost: {config.calculate_total_cost_per_hour():.2f} PED/hr")
print(f" Protection: {format_protection(config.get_total_protection())}")
sys.exit(0)
if __name__ == "__main__":
main()