932 lines
38 KiB
Python
932 lines
38 KiB
Python
"""
|
|
Lemontropia Suite - Loadout Manager UI
|
|
A PyQt6 dialog for configuring hunting gear loadouts with cost calculations.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, asdict
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path
|
|
from typing import Optional, List
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
|
QLineEdit, QComboBox, QLabel, QPushButton,
|
|
QGroupBox, QSpinBox, QDoubleSpinBox, QMessageBox,
|
|
QListWidget, QListWidgetItem, QSplitter, QWidget,
|
|
QFrame, QScrollArea, QGridLayout
|
|
)
|
|
from PyQt6.QtCore import Qt, pyqtSignal
|
|
from PyQt6.QtGui import QFont
|
|
|
|
|
|
# ============================================================================
|
|
# Data Structures
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class LoadoutConfig:
|
|
"""Configuration for a hunting loadout."""
|
|
name: str
|
|
weapon_name: str
|
|
weapon_damage: Decimal
|
|
weapon_decay_pec: Decimal
|
|
weapon_ammo_pec: Decimal
|
|
armor_name: str
|
|
armor_decay_pec: Decimal
|
|
heal_name: str
|
|
heal_cost_pec: Decimal
|
|
|
|
# Optional fields for extended calculations
|
|
shots_per_hour: int = 3600 # Default: 1 shot per second
|
|
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")
|
|
|
|
def calculate_dpp(self) -> Decimal:
|
|
"""Calculate Damage Per Pec (DPP) = Damage / (Decay + Ammo)."""
|
|
total_cost = self.weapon_decay_pec + self.weapon_ammo_pec
|
|
if total_cost == 0:
|
|
return Decimal("0")
|
|
return self.weapon_damage / total_cost
|
|
|
|
def calculate_cost_per_hour(self) -> Decimal:
|
|
"""Calculate total PED cost per hour based on shots per hour."""
|
|
cost_per_shot = self.weapon_decay_pec + self.weapon_ammo_pec
|
|
total_weapon_cost = cost_per_shot * Decimal(self.shots_per_hour)
|
|
|
|
# Estimate armor decay (assume 1 hit per 5 shots on average)
|
|
armor_cost_per_hour = self.armor_decay_pec * Decimal(self.shots_per_hour // 5)
|
|
|
|
# Estimate healing cost (assume 1 heal per 10 shots)
|
|
heal_cost_per_hour = self.heal_cost_pec * Decimal(self.shots_per_hour // 10)
|
|
|
|
total_pec = total_weapon_cost + armor_cost_per_hour + heal_cost_per_hour
|
|
return total_pec / Decimal("100") # Convert PEC to PED
|
|
|
|
def calculate_break_even(self, mob_health: Decimal) -> Decimal:
|
|
"""Calculate break-even loot value for a mob with given health."""
|
|
shots_to_kill = mob_health / self.weapon_damage
|
|
if shots_to_kill < 1:
|
|
shots_to_kill = Decimal("1")
|
|
|
|
cost_per_shot = self.weapon_decay_pec + self.weapon_ammo_pec
|
|
total_cost_pec = shots_to_kill * cost_per_shot
|
|
return total_cost_pec / Decimal("100") # Convert to PED
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dictionary for JSON serialization."""
|
|
return {
|
|
k: str(v) if isinstance(v, Decimal) else v
|
|
for k, v in asdict(self).items()
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "LoadoutConfig":
|
|
"""Create LoadoutConfig from dictionary."""
|
|
decimal_fields = [
|
|
'weapon_damage', 'weapon_decay_pec', 'weapon_ammo_pec',
|
|
'armor_decay_pec', 'heal_cost_pec', '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])
|
|
|
|
if 'shots_per_hour' in data:
|
|
data['shots_per_hour'] = int(data['shots_per_hour'])
|
|
|
|
# Handle legacy configs without heal_name
|
|
if 'heal_name' not in data:
|
|
data['heal_name'] = '-- Custom --'
|
|
|
|
return cls(**data)
|
|
|
|
|
|
# ============================================================================
|
|
# Mock Data
|
|
# ============================================================================
|
|
|
|
MOCK_WEAPONS = [
|
|
{"name": "Sollomate Opalo", "damage": Decimal("9.0"), "decay": Decimal("0.09"), "ammo": Decimal("1.80")},
|
|
{"name": "Omegaton M2100", "damage": Decimal("10.5"), "decay": Decimal("0.10"), "ammo": Decimal("2.10")},
|
|
{"name": "Breer M1a", "damage": Decimal("12.0"), "decay": Decimal("0.12"), "ammo": Decimal("2.40")},
|
|
{"name": "Justifier Mk.II", "damage": Decimal("25.0"), "decay": Decimal("0.25"), "ammo": Decimal("5.00")},
|
|
{"name": "Marber Bravo-Type", "damage": Decimal("45.0"), "decay": Decimal("0.45"), "ammo": Decimal("9.00")},
|
|
{"name": " Maddox IV", "damage": Decimal("80.0"), "decay": Decimal("0.80"), "ammo": Decimal("16.00")},
|
|
]
|
|
|
|
MOCK_ARMOR = [
|
|
{"name": "Pixie Harness", "decay": Decimal("0.05"), "impact": Decimal("4"), "cut": Decimal("3"), "stab": Decimal("2")},
|
|
{"name": "Shogun Harness", "decay": Decimal("0.12"), "impact": Decimal("8"), "cut": Decimal("6"), "stab": Decimal("5"), "burn": Decimal("4")},
|
|
{"name": "Ghost Harness", "decay": Decimal("0.20"), "impact": Decimal("12"), "cut": Decimal("10"), "stab": Decimal("9"), "burn": Decimal("8"), "cold": Decimal("7")},
|
|
{"name": "Vigilante Harness", "decay": Decimal("0.08"), "impact": Decimal("6"), "cut": Decimal("5"), "stab": Decimal("4")},
|
|
{"name": "Hermes Harness", "decay": Decimal("0.15"), "impact": Decimal("10"), "cut": Decimal("8"), "stab": Decimal("7"), "penetration": Decimal("5")},
|
|
]
|
|
|
|
MOCK_HEALING = [
|
|
{"name": "Vivo T10", "cost": Decimal("2.0")},
|
|
{"name": "Vivo T15", "cost": Decimal("3.5")},
|
|
{"name": "Vivo S10", "cost": Decimal("4.0")},
|
|
{"name": "Refurbished H.E.A.R.T.", "cost": Decimal("1.5")},
|
|
{"name": "Restoration Chip I", "cost": Decimal("5.0")},
|
|
{"name": "Restoration Chip II", "cost": Decimal("8.0")},
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# 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;
|
|
}
|
|
""")
|
|
|
|
|
|
# ============================================================================
|
|
# Main Dialog
|
|
# ============================================================================
|
|
|
|
class LoadoutManagerDialog(QDialog):
|
|
"""Main dialog for managing hunting loadouts."""
|
|
|
|
loadout_saved = pyqtSignal(str) # Emitted when loadout is saved
|
|
|
|
def __init__(self, parent=None, config_dir: Optional[str] = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Lemontropia Suite - Loadout Manager")
|
|
self.setMinimumSize(900, 700)
|
|
|
|
# Configuration directory
|
|
if config_dir is None:
|
|
self.config_dir = Path.home() / ".lemontropia" / "loadouts"
|
|
else:
|
|
self.config_dir = Path(config_dir)
|
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
self.current_loadout: Optional[LoadoutConfig] = None
|
|
|
|
self._apply_dark_theme()
|
|
self._create_widgets()
|
|
self._create_layout()
|
|
self._connect_signals()
|
|
self._load_saved_loadouts()
|
|
self._populate_mock_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;
|
|
}
|
|
QListWidget {
|
|
background-color: #2d2d2d;
|
|
color: #e0e0e0;
|
|
border: 1px solid #3d3d3d;
|
|
border-radius: 4px;
|
|
}
|
|
QListWidget::item:selected {
|
|
background-color: #4a90d9;
|
|
}
|
|
QScrollArea {
|
|
border: none;
|
|
}
|
|
""")
|
|
|
|
def _create_widgets(self):
|
|
"""Create all UI widgets."""
|
|
# Loadout name
|
|
self.loadout_name_edit = QLineEdit()
|
|
self.loadout_name_edit.setPlaceholderText("Enter loadout name...")
|
|
|
|
# Shots per hour
|
|
self.shots_per_hour_spin = QSpinBox()
|
|
self.shots_per_hour_spin.setRange(1, 10000)
|
|
self.shots_per_hour_spin.setValue(3600)
|
|
self.shots_per_hour_spin.setSuffix(" shots/hr")
|
|
|
|
# Weapon section
|
|
self.weapon_group = DarkGroupBox("🔫 Weapon Configuration")
|
|
self.weapon_combo = QComboBox()
|
|
self.weapon_combo.setEditable(False) # Show dropdown list on click
|
|
self.weapon_damage_edit = DecimalLineEdit()
|
|
self.weapon_decay_edit = DecimalLineEdit()
|
|
self.weapon_ammo_edit = DecimalLineEdit()
|
|
self.dpp_label = QLabel("0.00")
|
|
self.dpp_label.setStyleSheet("color: #4caf50; font-weight: bold; font-size: 14px;")
|
|
|
|
# Armor section
|
|
self.armor_group = DarkGroupBox("🛡️ Armor Configuration")
|
|
self.armor_combo = QComboBox()
|
|
self.armor_combo.setEditable(False) # Show dropdown list on click
|
|
self.armor_decay_edit = DecimalLineEdit()
|
|
|
|
# Protection values
|
|
self.protection_stab_edit = DecimalLineEdit()
|
|
self.protection_cut_edit = DecimalLineEdit()
|
|
self.protection_impact_edit = DecimalLineEdit()
|
|
self.protection_pen_edit = DecimalLineEdit()
|
|
self.protection_shrap_edit = DecimalLineEdit()
|
|
self.protection_burn_edit = DecimalLineEdit()
|
|
self.protection_cold_edit = DecimalLineEdit()
|
|
self.protection_acid_edit = DecimalLineEdit()
|
|
self.protection_elec_edit = DecimalLineEdit()
|
|
|
|
# Healing section
|
|
self.heal_group = DarkGroupBox("💊 Healing Configuration")
|
|
self.heal_combo = QComboBox()
|
|
self.heal_combo.setEditable(False) # Show dropdown list on click
|
|
self.heal_cost_edit = DecimalLineEdit()
|
|
|
|
# Cost summary
|
|
self.summary_group = DarkGroupBox("📊 Cost Summary")
|
|
self.cost_per_hour_label = QLabel("0.00 PED/hr")
|
|
self.cost_per_hour_label.setStyleSheet("color: #ff9800; font-weight: bold; font-size: 16px;")
|
|
self.break_even_label = QLabel("Break-even: 0.00 PED (mob health: 100)")
|
|
self.break_even_label.setStyleSheet("color: #4caf50;")
|
|
|
|
# Break-even calculator
|
|
self.mob_health_edit = DecimalLineEdit()
|
|
self.mob_health_edit.set_decimal(Decimal("100"))
|
|
self.calc_break_even_btn = QPushButton("Calculate Break-Even")
|
|
|
|
# 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")
|
|
|
|
# Refresh button for saved list
|
|
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)
|
|
name_layout.addWidget(QLabel("Shots/Hour:"))
|
|
name_layout.addWidget(self.shots_per_hour_spin)
|
|
right_layout.addLayout(name_layout)
|
|
|
|
# Weapon configuration
|
|
weapon_layout = QFormLayout(self.weapon_group)
|
|
weapon_layout.addRow("Weapon:", self.weapon_combo)
|
|
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("DPP (Damage Per Pec):", self.dpp_label)
|
|
right_layout.addWidget(self.weapon_group)
|
|
|
|
# Armor configuration
|
|
armor_layout = QFormLayout(self.armor_group)
|
|
armor_layout.addRow("Armor Set:", self.armor_combo)
|
|
armor_layout.addRow("Decay/hit (PEC):", self.armor_decay_edit)
|
|
|
|
# Protection grid
|
|
protection_frame = QFrame()
|
|
protection_layout = QGridLayout(protection_frame)
|
|
protection_layout.setSpacing(5)
|
|
|
|
protections = [
|
|
("Stab:", self.protection_stab_edit),
|
|
("Cut:", self.protection_cut_edit),
|
|
("Impact:", self.protection_impact_edit),
|
|
("Penetration:", self.protection_pen_edit),
|
|
("Shrapnel:", self.protection_shrap_edit),
|
|
("Burn:", self.protection_burn_edit),
|
|
("Cold:", self.protection_cold_edit),
|
|
("Acid:", self.protection_acid_edit),
|
|
("Electric:", self.protection_elec_edit),
|
|
]
|
|
|
|
for i, (label, edit) in enumerate(protections):
|
|
row = i // 3
|
|
col = (i % 3) * 2
|
|
protection_layout.addWidget(QLabel(label), row, col)
|
|
protection_layout.addWidget(edit, row, col + 1)
|
|
|
|
armor_layout.addRow("Protection Values:", protection_frame)
|
|
right_layout.addWidget(self.armor_group)
|
|
|
|
# Healing configuration
|
|
heal_layout = QFormLayout(self.heal_group)
|
|
heal_layout.addRow("Healing Tool:", self.heal_combo)
|
|
heal_layout.addRow("Cost/heal (PEC):", self.heal_cost_edit)
|
|
right_layout.addWidget(self.heal_group)
|
|
|
|
# Cost summary
|
|
summary_layout = QFormLayout(self.summary_group)
|
|
summary_layout.addRow("Estimated Cost:", self.cost_per_hour_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_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, 650])
|
|
|
|
main_layout.addWidget(splitter)
|
|
|
|
def _connect_signals(self):
|
|
"""Connect all signal handlers."""
|
|
# Weapon changes
|
|
self.weapon_combo.currentTextChanged.connect(self._on_weapon_changed)
|
|
self.weapon_damage_edit.textChanged.connect(self._update_calculations)
|
|
self.weapon_decay_edit.textChanged.connect(self._update_calculations)
|
|
self.weapon_ammo_edit.textChanged.connect(self._update_calculations)
|
|
|
|
# Armor changes
|
|
self.armor_combo.currentTextChanged.connect(self._on_armor_changed)
|
|
|
|
# Healing changes
|
|
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)
|
|
self.shots_per_hour_spin.valueChanged.connect(self._update_calculations)
|
|
|
|
# Double click on list
|
|
self.saved_list.itemDoubleClicked.connect(self._load_from_item)
|
|
|
|
def _populate_mock_data(self):
|
|
"""Populate combos with mock data."""
|
|
# Weapons
|
|
self.weapon_combo.addItem("-- Custom --")
|
|
for weapon in MOCK_WEAPONS:
|
|
self.weapon_combo.addItem(weapon["name"])
|
|
|
|
# Armor
|
|
self.armor_combo.addItem("-- Custom --")
|
|
for armor in MOCK_ARMOR:
|
|
self.armor_combo.addItem(armor["name"])
|
|
|
|
# Healing
|
|
self.heal_combo.addItem("-- Custom --")
|
|
for heal in MOCK_HEALING:
|
|
self.heal_combo.addItem(heal["name"])
|
|
|
|
# Set initial enabled state (all fields enabled for custom entry)
|
|
self.weapon_damage_edit.setEnabled(True)
|
|
self.weapon_decay_edit.setEnabled(True)
|
|
self.weapon_ammo_edit.setEnabled(True)
|
|
self.armor_decay_edit.setEnabled(True)
|
|
self.protection_stab_edit.setEnabled(True)
|
|
self.protection_cut_edit.setEnabled(True)
|
|
self.protection_impact_edit.setEnabled(True)
|
|
self.protection_pen_edit.setEnabled(True)
|
|
self.protection_shrap_edit.setEnabled(True)
|
|
self.protection_burn_edit.setEnabled(True)
|
|
self.protection_cold_edit.setEnabled(True)
|
|
self.protection_acid_edit.setEnabled(True)
|
|
self.protection_elec_edit.setEnabled(True)
|
|
self.heal_cost_edit.setEnabled(True)
|
|
|
|
def _on_weapon_changed(self, name: str):
|
|
"""Handle weapon selection change."""
|
|
if name == "-- Custom --":
|
|
# Enable manual entry for custom weapon
|
|
self.weapon_damage_edit.setEnabled(True)
|
|
self.weapon_decay_edit.setEnabled(True)
|
|
self.weapon_ammo_edit.setEnabled(True)
|
|
# Clear fields for user to enter custom values
|
|
self.weapon_damage_edit.clear()
|
|
self.weapon_decay_edit.clear()
|
|
self.weapon_ammo_edit.clear()
|
|
else:
|
|
# Auto-fill stats for predefined weapon and disable fields
|
|
for weapon in MOCK_WEAPONS:
|
|
if weapon["name"] == name:
|
|
self.weapon_damage_edit.set_decimal(weapon["damage"])
|
|
self.weapon_decay_edit.set_decimal(weapon["decay"])
|
|
self.weapon_ammo_edit.set_decimal(weapon["ammo"])
|
|
break
|
|
self.weapon_damage_edit.setEnabled(False)
|
|
self.weapon_decay_edit.setEnabled(False)
|
|
self.weapon_ammo_edit.setEnabled(False)
|
|
self._update_calculations()
|
|
|
|
def _on_armor_changed(self, name: str):
|
|
"""Handle armor selection change."""
|
|
if name == "-- Custom --":
|
|
# Enable manual entry for custom armor
|
|
self.armor_decay_edit.setEnabled(True)
|
|
self.protection_stab_edit.setEnabled(True)
|
|
self.protection_cut_edit.setEnabled(True)
|
|
self.protection_impact_edit.setEnabled(True)
|
|
self.protection_pen_edit.setEnabled(True)
|
|
self.protection_shrap_edit.setEnabled(True)
|
|
self.protection_burn_edit.setEnabled(True)
|
|
self.protection_cold_edit.setEnabled(True)
|
|
self.protection_acid_edit.setEnabled(True)
|
|
self.protection_elec_edit.setEnabled(True)
|
|
# Clear fields for user to enter custom values
|
|
self.armor_decay_edit.clear()
|
|
self.protection_stab_edit.clear()
|
|
self.protection_cut_edit.clear()
|
|
self.protection_impact_edit.clear()
|
|
self.protection_pen_edit.clear()
|
|
self.protection_shrap_edit.clear()
|
|
self.protection_burn_edit.clear()
|
|
self.protection_cold_edit.clear()
|
|
self.protection_acid_edit.clear()
|
|
self.protection_elec_edit.clear()
|
|
else:
|
|
# Auto-fill stats for predefined armor and disable fields
|
|
for armor in MOCK_ARMOR:
|
|
if armor["name"] == name:
|
|
self.armor_decay_edit.set_decimal(armor["decay"])
|
|
self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0")))
|
|
self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0")))
|
|
self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0")))
|
|
self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0")))
|
|
self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0")))
|
|
self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0")))
|
|
break
|
|
self.armor_decay_edit.setEnabled(False)
|
|
self.protection_stab_edit.setEnabled(False)
|
|
self.protection_cut_edit.setEnabled(False)
|
|
self.protection_impact_edit.setEnabled(False)
|
|
self.protection_pen_edit.setEnabled(False)
|
|
self.protection_shrap_edit.setEnabled(False)
|
|
self.protection_burn_edit.setEnabled(False)
|
|
self.protection_cold_edit.setEnabled(False)
|
|
self.protection_acid_edit.setEnabled(False)
|
|
self.protection_elec_edit.setEnabled(False)
|
|
|
|
def _on_heal_changed(self, name: str):
|
|
"""Handle healing selection change."""
|
|
if name == "-- Custom --":
|
|
# Enable manual entry for custom healing
|
|
self.heal_cost_edit.setEnabled(True)
|
|
# Clear field for user to enter custom value
|
|
self.heal_cost_edit.clear()
|
|
else:
|
|
# Auto-fill stats for predefined healing and disable field
|
|
for heal in MOCK_HEALING:
|
|
if heal["name"] == name:
|
|
self.heal_cost_edit.set_decimal(heal["cost"])
|
|
break
|
|
self.heal_cost_edit.setEnabled(False)
|
|
|
|
def _update_calculations(self):
|
|
"""Update DPP and cost calculations."""
|
|
try:
|
|
config = self._get_current_config()
|
|
|
|
# Update DPP
|
|
dpp = config.calculate_dpp()
|
|
self.dpp_label.setText(f"{dpp:.4f}")
|
|
|
|
# Update cost per hour
|
|
cost_per_hour = config.calculate_cost_per_hour()
|
|
self.cost_per_hour_label.setText(f"{cost_per_hour:.2f} PED/hr")
|
|
|
|
except Exception as e:
|
|
pass # Ignore calculation errors during typing
|
|
|
|
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 health: {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."""
|
|
return LoadoutConfig(
|
|
name=self.loadout_name_edit.text().strip() or "Unnamed",
|
|
weapon_name=self.weapon_combo.currentText(),
|
|
weapon_damage=self.weapon_damage_edit.get_decimal(),
|
|
weapon_decay_pec=self.weapon_decay_edit.get_decimal(),
|
|
weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(),
|
|
armor_name=self.armor_combo.currentText(),
|
|
armor_decay_pec=self.armor_decay_edit.get_decimal(),
|
|
heal_name=self.heal_combo.currentText(),
|
|
heal_cost_pec=self.heal_cost_edit.get_decimal(),
|
|
shots_per_hour=self.shots_per_hour_spin.value(),
|
|
protection_stab=self.protection_stab_edit.get_decimal(),
|
|
protection_cut=self.protection_cut_edit.get_decimal(),
|
|
protection_impact=self.protection_impact_edit.get_decimal(),
|
|
protection_penetration=self.protection_pen_edit.get_decimal(),
|
|
protection_shrapnel=self.protection_shrap_edit.get_decimal(),
|
|
protection_burn=self.protection_burn_edit.get_decimal(),
|
|
protection_cold=self.protection_cold_edit.get_decimal(),
|
|
protection_acid=self.protection_acid_edit.get_decimal(),
|
|
protection_electric=self.protection_elec_edit.get_decimal(),
|
|
)
|
|
|
|
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.weapon_combo.setCurrentText(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)
|
|
# Enable/disable based on whether it's a custom weapon
|
|
is_custom_weapon = config.weapon_name == "-- Custom --"
|
|
self.weapon_damage_edit.setEnabled(is_custom_weapon)
|
|
self.weapon_decay_edit.setEnabled(is_custom_weapon)
|
|
self.weapon_ammo_edit.setEnabled(is_custom_weapon)
|
|
|
|
self.armor_combo.setCurrentText(config.armor_name)
|
|
self.armor_decay_edit.set_decimal(config.armor_decay_pec)
|
|
|
|
self.protection_stab_edit.set_decimal(config.protection_stab)
|
|
self.protection_cut_edit.set_decimal(config.protection_cut)
|
|
self.protection_impact_edit.set_decimal(config.protection_impact)
|
|
self.protection_pen_edit.set_decimal(config.protection_penetration)
|
|
self.protection_shrap_edit.set_decimal(config.protection_shrapnel)
|
|
self.protection_burn_edit.set_decimal(config.protection_burn)
|
|
self.protection_cold_edit.set_decimal(config.protection_cold)
|
|
self.protection_acid_edit.set_decimal(config.protection_acid)
|
|
self.protection_elec_edit.set_decimal(config.protection_electric)
|
|
# Enable/disable based on whether it's custom armor
|
|
is_custom_armor = config.armor_name == "-- Custom --"
|
|
self.armor_decay_edit.setEnabled(is_custom_armor)
|
|
self.protection_stab_edit.setEnabled(is_custom_armor)
|
|
self.protection_cut_edit.setEnabled(is_custom_armor)
|
|
self.protection_impact_edit.setEnabled(is_custom_armor)
|
|
self.protection_pen_edit.setEnabled(is_custom_armor)
|
|
self.protection_shrap_edit.setEnabled(is_custom_armor)
|
|
self.protection_burn_edit.setEnabled(is_custom_armor)
|
|
self.protection_cold_edit.setEnabled(is_custom_armor)
|
|
self.protection_acid_edit.setEnabled(is_custom_armor)
|
|
self.protection_elec_edit.setEnabled(is_custom_armor)
|
|
|
|
self.heal_combo.setCurrentText(config.heal_name if hasattr(config, 'heal_name') else "-- Custom --")
|
|
self.heal_cost_edit.set_decimal(config.heal_cost_pec)
|
|
# Enable/disable based on whether it's custom healing
|
|
is_custom_heal = (config.heal_name if hasattr(config, 'heal_name') else "-- Custom --") == "-- Custom --"
|
|
self.heal_cost_edit.setEnabled(is_custom_heal)
|
|
|
|
self._update_calculations()
|
|
|
|
def _save_loadout(self):
|
|
"""Save current loadout to file."""
|
|
name = self.loadout_name_edit.text().strip()
|
|
if not name:
|
|
QMessageBox.warning(self, "Missing Name", "Please enter a loadout name")
|
|
return
|
|
|
|
# Sanitize filename
|
|
safe_name = "".join(c for c in name if c.isalnum() or c in "._- ").strip()
|
|
if not safe_name:
|
|
safe_name = "unnamed"
|
|
|
|
config = self._get_current_config()
|
|
config.name = name
|
|
|
|
filepath = self.config_dir / f"{safe_name}.json"
|
|
|
|
try:
|
|
with open(filepath, 'w') as f:
|
|
json.dump(config.to_dict(), f, indent=2)
|
|
|
|
self.current_loadout = config
|
|
self.loadout_saved.emit(name)
|
|
self._load_saved_loadouts()
|
|
|
|
QMessageBox.information(self, "Saved", f"Loadout '{name}' saved successfully!")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to save: {str(e)}")
|
|
|
|
def _load_saved_loadouts(self):
|
|
"""Load list of saved loadouts."""
|
|
self.saved_list.clear()
|
|
|
|
try:
|
|
for filepath in sorted(self.config_dir.glob("*.json")):
|
|
try:
|
|
with open(filepath, 'r') as f:
|
|
data = json.load(f)
|
|
config = LoadoutConfig.from_dict(data)
|
|
|
|
item = QListWidgetItem(f"📋 {config.name}")
|
|
item.setData(Qt.ItemDataRole.UserRole, str(filepath))
|
|
item.setToolTip(
|
|
f"Weapon: {config.weapon_name}\n"
|
|
f"Armor: {config.armor_name}\n"
|
|
f"DPP: {config.calculate_dpp():.2f}"
|
|
)
|
|
self.saved_list.addItem(item)
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
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)
|
|
self.current_loadout = 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_combo.setCurrentIndex(0) # "-- Custom --"
|
|
self.armor_combo.setCurrentIndex(0) # "-- Custom --"
|
|
self.heal_combo.setCurrentIndex(0) # "-- Custom --"
|
|
|
|
# Clear all fields
|
|
self.weapon_damage_edit.clear()
|
|
self.weapon_decay_edit.clear()
|
|
self.weapon_ammo_edit.clear()
|
|
self.armor_decay_edit.clear()
|
|
self.protection_stab_edit.clear()
|
|
self.protection_cut_edit.clear()
|
|
self.protection_impact_edit.clear()
|
|
self.protection_pen_edit.clear()
|
|
self.protection_shrap_edit.clear()
|
|
self.protection_burn_edit.clear()
|
|
self.protection_cold_edit.clear()
|
|
self.protection_acid_edit.clear()
|
|
self.protection_elec_edit.clear()
|
|
self.heal_cost_edit.clear()
|
|
|
|
# Enable all fields for custom entry (since "-- Custom --" is selected)
|
|
self.weapon_damage_edit.setEnabled(True)
|
|
self.weapon_decay_edit.setEnabled(True)
|
|
self.weapon_ammo_edit.setEnabled(True)
|
|
self.armor_decay_edit.setEnabled(True)
|
|
self.protection_stab_edit.setEnabled(True)
|
|
self.protection_cut_edit.setEnabled(True)
|
|
self.protection_impact_edit.setEnabled(True)
|
|
self.protection_pen_edit.setEnabled(True)
|
|
self.protection_shrap_edit.setEnabled(True)
|
|
self.protection_burn_edit.setEnabled(True)
|
|
self.protection_cold_edit.setEnabled(True)
|
|
self.protection_acid_edit.setEnabled(True)
|
|
self.protection_elec_edit.setEnabled(True)
|
|
self.heal_cost_edit.setEnabled(True)
|
|
|
|
self.mob_health_edit.set_decimal(Decimal("100"))
|
|
self.current_loadout = None
|
|
self._update_calculations()
|
|
|
|
def get_current_loadout(self) -> Optional[LoadoutConfig]:
|
|
"""Get the currently loaded/created loadout."""
|
|
return self.current_loadout
|
|
|
|
|
|
# ============================================================================
|
|
# Main entry point for testing
|
|
# ============================================================================
|
|
|
|
def main():
|
|
"""Run the loadout manager as a standalone application."""
|
|
import sys
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
|
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} (DPP: {config.calculate_dpp():.2f})")
|
|
print(f" Cost/hour: {config.calculate_cost_per_hour():.2f} PED")
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|