Lemontropia-Suite/ui/loadout_manager.py

2484 lines
97 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, NexusArmorSet, NexusHealingTool, NexusPlate,
NexusAttachment, NexusEnhancer, NexusRing, NexusClothing, NexusPet
)
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
# Weapon Enhancers (up to 10 slots)
weapon_enhancers: List[AttachmentConfig] = field(default_factory=list)
# Armor System
equipped_armor: Optional[EquippedArmor] = None
armor_set_name: str = "-- None --"
# Armor Plates (per slot)
armor_plates: Dict[str, AttachmentConfig] = field(default_factory=dict)
# 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")
# Accessories
left_ring: Optional[str] = None
right_ring: Optional[str] = None
clothing_items: List[str] = field(default_factory=list)
pet: Optional[str] = None
# Mindforce
mindforce_implant: Optional[str] = None
mindforce_decay_pec: Decimal = Decimal("0")
# Enhancers - tier-based (1 per tier, max 10 tiers)
# Format: {tier_number: enhancer}
enhancers: Dict[int, 'NexusEnhancer'] = field(default_factory=dict)
# 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 and enhancers."""
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
# Add enhancer decay from all tiers
for tier, enhancer in self.enhancers.items():
total += enhancer.decay
# Add mindforce decay if used
total += self.mindforce_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_dps(self) -> Decimal:
"""Calculate Damage Per Second (DPS) with all attachments."""
# Standard EU fire rate is typically 1 shot per second without attachments
# A scope may increase this, but we'll use a base of 1.0 for simplicity
shots_per_second = Decimal("1.0")
if self.weapon_scope:
# Scopes can increase fire rate, but let's be conservative
shots_per_second = Decimal("1.2")
return self.get_total_damage() * shots_per_second
def get_armor_decay_per_hit(self) -> Decimal:
"""Calculate armor decay cost per hit taken (in PED)."""
decay_per_hit = Decimal("0")
if self.equipped_armor:
decay_per_hit = self.equipped_armor.get_total_decay_per_hit()
else:
# Legacy fallback
decay_per_hit = self.armor_decay_pec
# Add plate decay costs
for slot, plate_config in self.armor_plates.items():
decay_per_hit += plate_config.decay_pec
return decay_per_hit / Decimal("100") # Convert PEC to PED
def get_heal_cost_per_use(self) -> Decimal:
"""Calculate healing cost per use (in PED)."""
return self.heal_cost_pec / Decimal("100")
def get_hp_per_pec(self) -> Decimal:
"""Calculate HP healed per PEC spent."""
if self.heal_cost_pec == 0:
return Decimal("0")
return self.heal_amount / self.heal_cost_pec
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 plates."""
base_cost = Decimal("0")
if self.equipped_armor:
base_cost = self.equipped_armor.get_total_decay_per_hit() * Decimal(self.hits_per_hour)
else:
# Legacy fallback
base_cost = self.armor_decay_pec * Decimal(self.hits_per_hour)
# Add plate decay costs
for slot, plate_config in self.armor_plates.items():
base_cost += plate_config.decay_pec * Decimal(self.hits_per_hour)
return base_cost
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 weapon enhancers
if self.weapon_enhancers:
data['weapon_enhancers'] = [e.to_dict() for e in self.weapon_enhancers]
# Handle armor plates
if self.armor_plates:
data['armor_plates'] = {k: v.to_dict() for k, v in self.armor_plates.items()}
# 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."""
try:
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:
try:
data[field] = Decimal(data[field])
except Exception as e:
logger.warning(f"Could not convert {field} to Decimal: {e}")
data[field] = Decimal("0")
# 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:
try:
data[field] = int(data[field])
except Exception as e:
logger.warning(f"Could not convert {field} to int: {e}")
data[field] = 0
# 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 weapon enhancers
if 'weapon_enhancers' in data and data['weapon_enhancers']:
data['weapon_enhancers'] = [AttachmentConfig.from_dict(e) for e in data['weapon_enhancers']]
else:
data['weapon_enhancers'] = []
# Handle armor plates - with validation
if 'armor_plates' in data and data['armor_plates']:
plates = {}
for k, v in data['armor_plates'].items():
try:
if isinstance(v, dict):
plates[k] = AttachmentConfig.from_dict(v)
else:
logger.warning(f"Invalid armor_plate value for {k}: {type(v)}")
except Exception as e:
logger.error(f"Error parsing armor_plate {k}: {e}")
data['armor_plates'] = plates
else:
data['armor_plates'] = {}
# Handle accessories
if 'clothing_items' not in data:
data['clothing_items'] = []
if 'left_ring' not in data:
data['left_ring'] = None
if 'right_ring' not in data:
data['right_ring'] = None
if 'pet' not in data:
data['pet'] = 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)
except Exception as e:
logger.error(f"Error in LoadoutConfig.from_dict: {e}")
logger.error(f"Data keys: {list(data.keys())}")
raise
# ============================================================================
# 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)
# Add plate search button
self.search_plate_btn = QPushButton("🔍")
self.search_plate_btn.setToolTip("Search plates from Nexus API")
self.search_plate_btn.setFixedWidth(40)
self.search_plate_btn.clicked.connect(self._on_search_plate)
layout.addWidget(self.search_plate_btn)
# 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_search_plate(self):
"""Open plate selector dialog from Nexus API."""
from ui.plate_selector import PlateSelectorDialog
# Get current piece's protection to suggest matching plates
preferred_type = ""
if self.current_piece:
# Find highest protection type
protections = {
'impact': self.current_piece.protection.impact,
'cut': self.current_piece.protection.cut,
'stab': self.current_piece.protection.stab,
'burn': self.current_piece.protection.burn,
'cold': self.current_piece.protection.cold,
}
preferred_type = max(protections, key=protections.get)
dialog = PlateSelectorDialog(self, damage_type=preferred_type)
dialog.plate_selected.connect(self._on_api_plate_selected)
dialog.exec()
def _on_api_plate_selected(self, plate: NexusPlate):
"""Handle plate selection from API."""
# Add to combo if not exists
index = self.plate_combo.findText(plate.name)
if index < 0:
# Create ArmorPlate from NexusPlate
from core.armor_system import ArmorPlate as LocalArmorPlate
local_plate = LocalArmorPlate(
name=plate.name,
item_id=plate.item_id,
protection=plate.protection,
durability=plate.durability,
decay_per_hp=plate.decay_per_hp
)
display = f"{plate.name} (+{plate.protection_impact + plate.protection_cut + plate.protection_stab} prot)"
self.plate_combo.addItem(display, local_plate)
index = self.plate_combo.count() - 1
self.plate_combo.setCurrentIndex(index)
self._update_total()
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)
self.current_piece = None
self.protection_label.setText("-")
self._update_total()
return
# Store the piece
self.current_piece = piece
# Update protection display
prot_parts = []
if piece.protection.impact > 0:
prot_parts.append(f"Imp:{piece.protection.impact}")
if piece.protection.cut > 0:
prot_parts.append(f"Cut:{piece.protection.cut}")
if piece.protection.stab > 0:
prot_parts.append(f"Stab:{piece.protection.stab}")
self.protection_label.setText(", ".join(prot_parts) if prot_parts else "-")
# Try to find and select the piece in combo
for i in range(self.piece_combo.count()):
data = self.piece_combo.itemData(i)
if data and hasattr(data, 'item_id') and data.item_id == piece.item_id:
self.piece_combo.setCurrentIndex(i)
self._update_total()
return
# Piece not in combo - add it
display = f"{piece.name} (API)"
self.piece_combo.addItem(display, piece)
self.piece_combo.setCurrentIndex(self.piece_combo.count() - 1)
self._update_total()
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(object)
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
self.current_left_ring: Optional[NexusRing] = None
self.current_right_ring: Optional[NexusRing] = 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...")
# 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 - refined metrics (cost per action, not per hour)
self.summary_group = DarkGroupBox("📊 Cost Analysis")
# Weapon metrics
self.cost_per_shot_label = QLabel("0.0000 PED")
self.dps_label = QLabel("0.00 DPS")
self.dpp_label = QLabel("0.0000 DPP")
self.dpp_label.setStyleSheet("color: #4caf50; font-weight: bold;")
# Armor metrics
self.cost_per_hit_label = QLabel("0.0000 PED")
self.protection_summary_label = QLabel("No protection")
self.protection_summary_label.setStyleSheet("color: #4a90d9;")
# Healing metrics
self.cost_per_heal_label = QLabel("0.0000 PED")
self.hp_per_pec_label = QLabel("0.00 HP/PEC")
self.hp_per_pec_label.setStyleSheet("color: #4caf50;")
# Total cost display
self.total_cost_label = QLabel("0.0000 PED")
self.total_cost_label.setStyleSheet("color: #ff9800; font-weight: bold; font-size: 16px;")
# 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)
# 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)
self.attach_amp_btn.setText("🔍 Search Amps")
self.attach_amp_btn.clicked.connect(lambda: self._on_select_attachment("amplifier"))
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)
self.attach_scope_btn.setText("🔍 Search Scopes")
self.attach_scope_btn.clicked.connect(lambda: self._on_select_attachment("scope"))
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)
self.attach_absorber_btn.setText("🔍 Search Absorbers")
self.attach_absorber_btn.clicked.connect(lambda: self._on_select_attachment("absorber"))
attachments_layout.addWidget(self.attach_absorber_btn, 2, 2)
attachments_layout.addWidget(self.remove_absorber_btn, 2, 3)
# Add enhancer selection button
self.select_enhancer_btn = QPushButton("✨ Select Enhancers")
self.select_enhancer_btn.setObjectName("selectButton")
self.select_enhancer_btn.clicked.connect(self._on_select_enhancer)
attachments_layout.addWidget(self.select_enhancer_btn, 3, 0, 1, 4)
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)
# Accessories section (Rings, Clothing, Pets)
self.accessories_group = DarkGroupBox("💍 Accessories")
accessories_layout = QVBoxLayout(self.accessories_group)
# Rings - Left and Right
rings_layout = QHBoxLayout()
left_ring_layout = QVBoxLayout()
left_ring_layout.addWidget(QLabel("Left Ring:"))
self.left_ring_label = QLabel("None")
self.left_ring_label.setStyleSheet("color: #888888;")
left_ring_layout.addWidget(self.left_ring_label)
self.select_left_ring_btn = QPushButton("🔍 Select")
self.select_left_ring_btn.setObjectName("selectButton")
self.select_left_ring_btn.clicked.connect(self._on_select_left_ring)
left_ring_layout.addWidget(self.select_left_ring_btn)
rings_layout.addLayout(left_ring_layout)
right_ring_layout = QVBoxLayout()
right_ring_layout.addWidget(QLabel("Right Ring:"))
self.right_ring_label = QLabel("None")
self.right_ring_label.setStyleSheet("color: #888888;")
right_ring_layout.addWidget(self.right_ring_label)
self.select_right_ring_btn = QPushButton("🔍 Select")
self.select_right_ring_btn.setObjectName("selectButton")
self.select_right_ring_btn.clicked.connect(self._on_select_right_ring)
right_ring_layout.addWidget(self.select_right_ring_btn)
rings_layout.addLayout(right_ring_layout)
accessories_layout.addLayout(rings_layout)
# Clothing and Pets
other_accessories_layout = QHBoxLayout()
self.select_clothing_btn = QPushButton("👕 Clothing")
self.select_clothing_btn.setObjectName("selectButton")
self.select_clothing_btn.clicked.connect(self._on_select_clothing)
other_accessories_layout.addWidget(self.select_clothing_btn)
self.select_pet_btn = QPushButton("🐾 Pet")
self.select_pet_btn.setObjectName("selectButton")
self.select_pet_btn.clicked.connect(self._on_select_pet)
other_accessories_layout.addWidget(self.select_pet_btn)
accessories_layout.addLayout(other_accessories_layout)
right_layout.addWidget(self.accessories_group)
right_layout.addWidget(self.accessories_group)
# Cost summary - refined layout with better metrics
summary_layout = QFormLayout(self.summary_group)
# Weapon section
weapon_group = QLabel("<b>⚔️ Weapon</b>")
weapon_group.setStyleSheet("color: #e0e0e0; margin-top: 5px;")
summary_layout.addRow(weapon_group)
summary_layout.addRow(" Cost/Shot:", self.cost_per_shot_label)
summary_layout.addRow(" DPS:", self.dps_label)
summary_layout.addRow(" DPP:", self.dpp_label)
# Armor section
armor_group = QLabel("<b>🛡️ Armor</b>")
armor_group.setStyleSheet("color: #e0e0e0; margin-top: 5px;")
summary_layout.addRow(armor_group)
summary_layout.addRow(" Cost/Hit:", self.cost_per_hit_label)
summary_layout.addRow(" Protection:", self.protection_summary_label)
# Healing section
heal_group = QLabel("<b>💚 Healing</b>")
heal_group.setStyleSheet("color: #e0e0e0; margin-top: 5px;")
summary_layout.addRow(heal_group)
summary_layout.addRow(" Cost/Heal:", self.cost_per_heal_label)
summary_layout.addRow(" HP/PEC:", self.hp_per_pec_label)
# Break-even calculator
break_even_group = QLabel("<b>📈 Break-Even</b>")
break_even_group.setStyleSheet("color: #e0e0e0; margin-top: 10px;")
summary_layout.addRow(break_even_group)
break_even_layout = QHBoxLayout()
break_even_layout.addWidget(QLabel("Mob HP:"))
break_even_layout.addWidget(self.mob_health_edit)
break_even_layout.addWidget(self.calc_break_even_btn)
summary_layout.addRow(" Calculate:", break_even_layout)
summary_layout.addRow(" Break-even:", 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)
# 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.armor_set_selected.connect(self._on_api_armor_set_selected)
dialog.exec()
def _on_api_armor_selected(self, armor: NexusArmor):
"""Handle individual armor piece selection from API."""
# Store selected armor info
self._selected_api_armor = armor
QMessageBox.information(
self,
"Armor Selected",
f"Selected: {armor.name}\n"
f"Type: {armor.type}\n"
f"Durability: {armor.durability}\n"
f"Protection: Impact {armor.protection_impact}, Cut {armor.protection_cut}, Stab {armor.protection_stab}"
)
def _on_api_armor_set_selected(self, armor_set: 'NexusArmorSet'):
"""Handle full armor set selection from API."""
from core.nexus_full_api import get_nexus_api
# Get all armors to find matching pieces with full data
api = get_nexus_api()
all_armors = api.get_all_armors()
# Map slot names to armor slot widgets
slot_mapping = {
'head': self.slot_widgets[ArmorSlot.HEAD],
'torso': self.slot_widgets[ArmorSlot.TORSO],
'harness': self.slot_widgets[ArmorSlot.TORSO],
'chest': self.slot_widgets[ArmorSlot.TORSO],
'arms': self.slot_widgets[ArmorSlot.ARMS],
'arm guards': self.slot_widgets[ArmorSlot.ARMS],
'armguards': self.slot_widgets[ArmorSlot.ARMS],
'hands': self.slot_widgets[ArmorSlot.HANDS],
'gloves': self.slot_widgets[ArmorSlot.HANDS],
'legs': self.slot_widgets[ArmorSlot.LEGS],
'thigh guards': self.slot_widgets[ArmorSlot.LEGS],
'thighguards': self.slot_widgets[ArmorSlot.LEGS],
'shins': self.slot_widgets[ArmorSlot.SHINS],
'shin guards': self.slot_widgets[ArmorSlot.SHINS],
'shinguards': self.slot_widgets[ArmorSlot.SHINS],
'feet': self.slot_widgets[ArmorSlot.FEET],
'foot guards': self.slot_widgets[ArmorSlot.FEET],
'footguards': self.slot_widgets[ArmorSlot.FEET],
}
pieces_found = 0
pieces_not_found = []
# Store the full set for protection calculations
self.current_armor_set = armor_set
# Try to find each piece in the set
for piece_name in armor_set.pieces:
# Find the armor in the API results
matching_armor = None
for armor in all_armors:
if armor.name == piece_name:
matching_armor = armor
break
if matching_armor:
# Get slot from the armor's Type field or parse from name
armor_type = matching_armor.type.lower()
slot_widget = None
# First try direct Type match
if armor_type in slot_mapping:
slot_widget = slot_mapping[armor_type]
else:
# Try parsing from armor type
for type_key, widget in slot_mapping.items():
if type_key in armor_type:
slot_widget = widget
break
# If still no match, try parsing from name
if not slot_widget:
name_lower = piece_name.lower()
if 'helmet' in name_lower or 'cap' in name_lower:
slot_widget = self.slot_widgets[ArmorSlot.HEAD]
elif 'harness' in name_lower or 'chest' in name_lower or 'torso' in name_lower:
slot_widget = self.slot_widgets[ArmorSlot.TORSO]
elif 'arm' in name_lower or 'shoulder' in name_lower:
slot_widget = self.slot_widgets[ArmorSlot.ARMS]
elif 'glove' in name_lower or 'hand' in name_lower:
slot_widget = self.slot_widgets[ArmorSlot.HANDS]
elif 'thigh' in name_lower or ('leg' in name_lower and 'shin' not in name_lower):
slot_widget = self.slot_widgets[ArmorSlot.LEGS]
elif 'shin' in name_lower:
slot_widget = self.slot_widgets[ArmorSlot.SHINS]
elif 'foot' in name_lower or 'boot' in name_lower:
slot_widget = self.slot_widgets[ArmorSlot.FEET]
if slot_widget:
# Create ArmorPiece from NexusArmor
# Use the SET'S protection values, not individual piece (which are 0 in API)
from core.armor_system import ArmorPiece
piece = ArmorPiece(
item_id=matching_armor.item_id,
name=matching_armor.name,
slot=self._get_slot_from_type(armor_type) if armor_type in slot_mapping else self._get_slot_from_name(piece_name),
# Use armor set's total protection for each piece
# In EU, protection comes from the full set, not individual pieces
protection=ProtectionProfile(
impact=armor_set.total_protection.impact,
cut=armor_set.total_protection.cut,
stab=armor_set.total_protection.stab,
burn=armor_set.total_protection.burn,
cold=armor_set.total_protection.cold,
acid=armor_set.total_protection.acid,
electric=armor_set.total_protection.electric,
),
durability=matching_armor.durability,
decay_per_hp=Decimal("0.05") * (Decimal(1) - Decimal(matching_armor.durability) / Decimal("100000"))
)
slot_widget.set_piece(piece)
pieces_found += 1
else:
pieces_not_found.append(f"{piece_name} (unknown slot: {armor_type})")
else:
pieces_not_found.append(piece_name)
# Show summary
msg = f"Equipped armor set: {armor_set.name}\n\n"
msg += f"✓ Found and equipped {pieces_found}/{len(armor_set.pieces)} pieces\n"
if pieces_not_found:
msg += f"\n⚠ Not found:\n" + "\n".join([f"{p}" for p in pieces_not_found])
if armor_set.set_bonus:
msg += f"\n\n✨ Set Bonus: {armor_set.set_bonus}"
QMessageBox.information(self, "Armor Set Equipped", msg)
self._update_calculations()
def _get_slot_from_type(self, armor_type: str) -> 'ArmorSlot':
"""Map armor type string to ArmorSlot enum."""
armor_type = armor_type.lower()
if 'head' in armor_type or 'helmet' in armor_type:
return ArmorSlot.HEAD
elif 'torso' in armor_type or 'harness' in armor_type or 'chest' in armor_type:
return ArmorSlot.TORSO
elif 'arm' in armor_type:
return ArmorSlot.ARMS
elif 'hand' in armor_type or 'glove' in armor_type:
return ArmorSlot.HANDS
elif 'thigh' in armor_type or ('leg' in armor_type and 'shin' not in armor_type):
return ArmorSlot.LEGS
elif 'shin' in armor_type:
return ArmorSlot.SHINS
elif 'foot' in armor_type or 'boot' in armor_type:
return ArmorSlot.FEET
else:
return ArmorSlot.TORSO # Default
def _get_slot_from_name(self, piece_name: str) -> 'ArmorSlot':
"""Map armor piece name to ArmorSlot enum."""
name_lower = piece_name.lower()
if 'helmet' in name_lower or 'cap' in name_lower:
return ArmorSlot.HEAD
elif 'harness' in name_lower or 'chest' in name_lower or 'torso' in name_lower:
return ArmorSlot.TORSO
elif 'arm' in name_lower or 'shoulder' in name_lower:
return ArmorSlot.ARMS
elif 'glove' in name_lower or 'hand' in name_lower:
return ArmorSlot.HANDS
elif 'thigh' in name_lower or ('leg' in name_lower and 'shin' not in name_lower):
return ArmorSlot.LEGS
elif 'shin' in name_lower:
return ArmorSlot.SHINS
elif 'foot' in name_lower or 'boot' in name_lower:
return ArmorSlot.FEET
else:
return ArmorSlot.TORSO # Default
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_select_attachment(self, attachment_type: str):
"""Open attachment selector dialog from Nexus API."""
from ui.attachment_selector import AttachmentSelectorDialog
dialog = AttachmentSelectorDialog(self, attachment_type=attachment_type)
dialog.attachment_selected.connect(
lambda att: self._on_api_attachment_selected(att, attachment_type)
)
dialog.exec()
def _on_api_attachment_selected(self, attachment: NexusAttachment, att_type: str):
"""Handle attachment selection from API."""
# Update UI based on attachment type
if att_type == "amplifier":
self.amp_label.setText(f"{attachment.name} (+{attachment.damage_bonus} dmg)")
elif att_type == "scope":
self.scope_label.setText(f"{attachment.name} (+{attachment.range_bonus} rng)")
elif att_type == "absorber":
self.absorber_label.setText(f"{attachment.name}")
QMessageBox.information(
self,
"Attachment Selected",
f"Selected: {attachment.name}\n"
f"Type: {attachment.attachment_type.title()}\n"
f"Damage: +{attachment.damage_bonus}\n"
f"Range: +{attachment.range_bonus}\n"
f"Decay: {attachment.decay:.2f} PEC"
)
def _on_select_enhancer(self):
"""Open enhancer selector dialog."""
from ui.enhancer_selector import EnhancerSelectorDialog
dialog = EnhancerSelectorDialog(self)
dialog.enhancer_selected.connect(self._on_api_enhancer_selected)
dialog.exec()
def _on_api_enhancer_selected(self, enhancer: NexusEnhancer):
"""Handle enhancer selection from API."""
QMessageBox.information(
self,
"Enhancer Selected",
f"Selected: {enhancer.name}\n"
f"Type: {enhancer.enhancer_type.title()}\n"
f"Tier: {enhancer.tier}\n"
f"Effect: +{enhancer.effect_value}%\n"
f"Break Chance: {enhancer.break_chance * 100:.1f}%"
)
def _on_select_left_ring(self):
"""Open ring selector for left finger."""
from ui.accessories_selector import AccessoriesSelectorDialog
dialog = AccessoriesSelectorDialog(self, slot_filter="Left Finger")
dialog.ring_selected.connect(self._on_left_ring_selected)
dialog.exec()
def _on_select_right_ring(self):
"""Open ring selector for right finger."""
from ui.accessories_selector import AccessoriesSelectorDialog
dialog = AccessoriesSelectorDialog(self, slot_filter="Right Finger")
dialog.ring_selected.connect(self._on_right_ring_selected)
dialog.exec()
def _on_left_ring_selected(self, ring: NexusRing):
"""Handle left ring selection."""
self.current_left_ring = ring
effects_str = ", ".join([f"{k}: {v}" for k, v in ring.effects.items()]) if ring.effects else "No effects"
self.left_ring_label.setText(f"{ring.name}\n{effects_str}")
self.left_ring_label.setStyleSheet("color: #4caf50;")
def _on_right_ring_selected(self, ring: NexusRing):
"""Handle right ring selection."""
self.current_right_ring = ring
effects_str = ", ".join([f"{k}: {v}" for k, v in ring.effects.items()]) if ring.effects else "No effects"
self.right_ring_label.setText(f"{ring.name}\n{effects_str}")
self.right_ring_label.setStyleSheet("color: #4caf50;")
def _on_select_clothing(self):
"""Open clothing selector."""
from ui.accessories_selector import AccessoriesSelectorDialog
dialog = AccessoriesSelectorDialog(self, initial_tab="clothing")
dialog.clothing_selected.connect(self._on_clothing_selected)
dialog.exec()
def _on_select_pet(self):
"""Open pet selector."""
from ui.accessories_selector import AccessoriesSelectorDialog
dialog = AccessoriesSelectorDialog(self, initial_tab="pets")
dialog.pet_selected.connect(self._on_pet_selected)
dialog.exec()
def _on_select_accessories(self):
"""Open accessories selector dialog (rings, clothing, pets) - legacy."""
self._on_select_left_ring()
def _on_ring_selected(self, ring: NexusRing):
"""Handle ring selection - legacy, routes to appropriate slot."""
if ring.slot == "Left Finger":
self._on_left_ring_selected(ring)
else:
self._on_right_ring_selected(ring)
def _on_clothing_selected(self, clothing: NexusClothing):
"""Handle clothing selection."""
buffs = ", ".join([f"{k}:{v}" for k, v in clothing.buffs.items()])
QMessageBox.information(
self,
"Clothing Selected",
f"Selected: {clothing.name}\n"
f"Slot: {clothing.slot}\n"
f"Buffs: {buffs if buffs else 'None'}"
)
def _on_pet_selected(self, pet: NexusPet):
"""Handle pet selection."""
QMessageBox.information(
self,
"Pet Selected",
f"Selected: {pet.name}\n"
f"Effect: {pet.effect_type} {pet.effect_value}\n"
f"Level Required: {pet.level_required if pet.level_required > 0 else 'None'}"
)
def _on_attach(self, attachment_type: str):
"""Handle attachment selection (legacy - now uses API)."""
self._on_select_attachment(attachment_type)
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
# Note: current_armor_set is a NexusArmorSet which has pieces as List[str]
# We can't easily check if pieces match, so just clear the set reference
if self.current_armor_set:
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 performance calculations."""
try:
config = self._get_current_config()
# DEBUG: Log armor status
if config.equipped_armor:
pieces = config.equipped_armor.get_all_pieces()
logger.debug(f"_update_calculations: {len(pieces)} pieces equipped")
for slot, piece in pieces.items():
logger.debug(f" {slot}: {piece.name}, prot={piece.protection.get_total()}")
else:
logger.debug("_update_calculations: No equipped_armor")
# Weapon metrics (per shot, not per hour)
cost_per_shot_pec = config.get_total_decay_per_shot() + config.get_total_ammo_per_shot()
cost_per_shot_ped = cost_per_shot_pec / Decimal("100") # Convert PEC to PED
self.cost_per_shot_label.setText(f"{cost_per_shot_ped:.4f} PED")
# DPS calculation
dps = config.calculate_dps()
self.dps_label.setText(f"{dps:.2f}")
# DPP (Damage Per Pec)
dpp = config.calculate_dpp()
self.dpp_label.setText(f"{dpp:.4f}")
# Armor metrics (cost per hit)
cost_per_hit = config.get_armor_decay_per_hit() # Already returns PED
self.cost_per_hit_label.setText(f"{cost_per_hit:.4f} PED")
# 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():.1f} | {prot_text}")
# Healing metrics
cost_per_heal = config.get_heal_cost_per_use() # Already returns PED
self.cost_per_heal_label.setText(f"{cost_per_heal:.4f} PED")
hp_per_pec = config.get_hp_per_pec()
self.hp_per_pec_label.setText(f"{hp_per_pec:.2f}")
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()
piece_count = 0
for slot, widget in self.slot_widgets.items():
piece = widget.get_piece()
if piece:
piece_count += 1
logger.debug(f"_get_current_config: Found piece in {slot}: {piece.name}, prot={piece.protection.get_total()}")
# Create a copy
piece_copy = ArmorPiece(
name=piece.name,
item_id=piece.item_id,
slot=piece.slot,
set_name=piece.set_name,
decay_per_hp=piece.decay_per_hp,
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_hp=plate.decay_per_hp,
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)
logger.debug(f"_get_current_config: Total pieces equipped: {piece_count}")
logger.debug(f"_get_current_config: equipped.get_all_pieces() returns {len(equipped.get_all_pieces())} pieces")
# Note: We don't call equip_full_set here because:
# 1. current_armor_set is a NexusArmorSet (API type), not ArmorSet (core type)
# 2. Individual pieces already have the full set protection values stored on them
# 3. The EquippedArmor.get_total_protection() will sum up pieces correctly
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=3600, # Default, no longer in UI
hits_per_hour=720, # Default, no longer in UI
heals_per_hour=60, # Default, no longer in UI
)
def _set_config(self, config: LoadoutConfig):
"""Set UI fields from configuration."""
self.loadout_name_edit.setText(config.name)
# 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(config)
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.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 cfg: print(f"Loadout saved: {cfg.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()