feat(swarm): Agent swarm - Loadout Manager v2.0, Armor Decay, Attachments

- Loadout Manager v2.0 with full Nexus API integration (3,099 weapons, 1,985 armors)
- Attachment system: Amplifiers, Scopes, Absorbers, Armor Platings
- Weapon/Armor/Attachment selectors with real data
- Armor decay tracking when hit (cost added to HUD)
- Complete cost calculations (weapon + armor + attachments + healing)
- NEW: ui/attachment_selector.py standalone module
- Updated core/nexus_api.py with decay fields
- DPP display in HUD overlay
This commit is contained in:
LemonNexus 2026-02-09 09:28:41 +00:00
parent 32e095350b
commit d24d5e149e
6 changed files with 2053 additions and 272 deletions

View File

@ -132,6 +132,7 @@ class ArmorStats:
name: str name: str
weight: Decimal weight: Decimal
durability: int durability: int
decay: Optional[Decimal] # Decay in PEC per hit taken
protection_stab: Decimal protection_stab: Decimal
protection_cut: Decimal protection_cut: Decimal
protection_impact: Decimal protection_impact: Decimal
@ -154,6 +155,7 @@ class ArmorStats:
def from_api_data(cls, data: Dict[str, Any]) -> 'ArmorStats': def from_api_data(cls, data: Dict[str, Any]) -> 'ArmorStats':
props = data.get('Properties', {}) props = data.get('Properties', {})
protection = props.get('Protection', {}) protection = props.get('Protection', {})
economy = props.get('Economy', {})
return cls( return cls(
id=data.get('Id', 0), id=data.get('Id', 0),
@ -161,6 +163,7 @@ class ArmorStats:
name=data.get('Name', 'Unknown'), name=data.get('Name', 'Unknown'),
weight=Decimal(str(props.get('Weight', 0))) if props.get('Weight') else Decimal('0'), weight=Decimal(str(props.get('Weight', 0))) if props.get('Weight') else Decimal('0'),
durability=props.get('Durability', 0) or 0, durability=props.get('Durability', 0) or 0,
decay=Decimal(str(economy.get('Decay'))) if economy.get('Decay') else None,
protection_stab=Decimal(str(protection.get('Stab', 0))) if protection.get('Stab') else Decimal('0'), protection_stab=Decimal(str(protection.get('Stab', 0))) if protection.get('Stab') else Decimal('0'),
protection_cut=Decimal(str(protection.get('Cut', 0))) if protection.get('Cut') else Decimal('0'), protection_cut=Decimal(str(protection.get('Cut', 0))) if protection.get('Cut') else Decimal('0'),
protection_impact=Decimal(str(protection.get('Impact', 0))) if protection.get('Impact') else Decimal('0'), protection_impact=Decimal(str(protection.get('Impact', 0))) if protection.get('Impact') else Decimal('0'),
@ -255,6 +258,66 @@ class MobStats:
) )
@dataclass
class MedicalTool:
"""Medical tool (FAP) statistics from Entropia Nexus API."""
id: int
item_id: int
name: str
weight: Optional[Decimal]
max_heal: Optional[Decimal]
min_heal: Optional[Decimal]
uses_per_minute: Optional[int]
max_tt: Optional[Decimal]
min_tt: Optional[Decimal]
decay: Optional[Decimal] # Decay per use in PEC
sib: bool = False
skill_start: Optional[Decimal] = None
skill_end: Optional[Decimal] = None
# Computed fields
cost_per_heal: Decimal = field(default_factory=lambda: Decimal("0"))
cost_per_hour: Decimal = field(default_factory=lambda: Decimal("0"))
def __post_init__(self):
"""Calculate derived statistics."""
# Calculate cost per heal (decay in PEC converted to PED)
if self.decay and self.decay > 0:
self.cost_per_heal = self.decay / Decimal("100")
# Calculate cost per hour
if self.uses_per_minute and self.uses_per_minute > 0 and self.decay:
uses_per_hour = self.uses_per_minute * 60
self.cost_per_hour = (self.decay * uses_per_hour) / 100
@classmethod
def from_api_data(cls, data: Dict[str, Any]) -> 'MedicalTool':
"""Create MedicalTool from API response data."""
props = data.get('Properties', {})
economy = props.get('Economy', {})
skill = props.get('Skill', {})
# Parse heal values
max_heal = props.get('MaxHeal')
min_heal = props.get('MinHeal')
return cls(
id=data.get('Id', 0),
item_id=data.get('ItemId', 0),
name=data.get('Name', 'Unknown'),
weight=Decimal(str(props.get('Weight'))) if props.get('Weight') else None,
max_heal=Decimal(str(max_heal)) if max_heal else None,
min_heal=Decimal(str(min_heal)) if min_heal else None,
uses_per_minute=props.get('UsesPerMinute'),
max_tt=Decimal(str(economy.get('MaxTT'))) if economy.get('MaxTT') else None,
min_tt=Decimal(str(economy.get('MinTT'))) if economy.get('MinTT') else None,
decay=Decimal(str(economy.get('Decay'))) if economy.get('Decay') else None,
sib=skill.get('IsSiB', False),
skill_start=Decimal(str(skill.get('LearningIntervalStart'))) if skill.get('LearningIntervalStart') else None,
skill_end=Decimal(str(skill.get('LearningIntervalEnd'))) if skill.get('LearningIntervalEnd') else None,
)
# ============================================================================= # =============================================================================
# API Client # API Client
# ============================================================================= # =============================================================================
@ -411,6 +474,30 @@ class EntropiaNexusAPI:
query_lower = query.lower() query_lower = query.lower()
return [m for m in mobs if query_lower in m.name.lower()] return [m for m in mobs if query_lower in m.name.lower()]
# ========================================================================
# Medical Tools (FAPs)
# ========================================================================
def get_all_medical_tools(self) -> List[MedicalTool]:
"""Get all medical tools (FAPs)."""
data = self._make_request('medicaltools')
if data:
return [MedicalTool.from_api_data(m) for m in data]
return []
def get_medical_tool(self, tool_id: int) -> Optional[MedicalTool]:
"""Get specific medical tool by ID."""
data = self._get_item('medicaltools', tool_id)
if data:
return MedicalTool.from_api_data(data)
return None
def search_medical_tools(self, query: str) -> List[MedicalTool]:
"""Search medical tools by name."""
tools = self.get_all_medical_tools()
query_lower = query.lower()
return [t for t in tools if query_lower in t.name.lower()]
# ======================================================================== # ========================================================================
# Cache Management # Cache Management
# ======================================================================== # ========================================================================
@ -424,5 +511,5 @@ class EntropiaNexusAPI:
__all__ = [ __all__ = [
'WeaponStats', 'ArmorStats', 'FinderStats', 'ExcavatorStats', 'MobStats', 'WeaponStats', 'ArmorStats', 'FinderStats', 'ExcavatorStats', 'MobStats',
'EntropiaNexusAPI' 'MedicalTool', 'EntropiaNexusAPI'
] ]

View File

@ -3,7 +3,11 @@
from .main_window import MainWindow from .main_window import MainWindow
from .hud_overlay import HUDOverlay, HUDStats from .hud_overlay import HUDOverlay, HUDStats
from .loadout_manager import LoadoutManagerDialog, LoadoutConfig from .loadout_manager import (
LoadoutManagerDialog, LoadoutConfig, AttachmentConfig,
WeaponSelectorDialog, ArmorSelectorDialog
)
from .attachment_selector import AttachmentSelectorDialog, AttachmentManagerWidget
__all__ = [ __all__ = [
'MainWindow', 'MainWindow',
@ -11,4 +15,9 @@ __all__ = [
'HUDStats', 'HUDStats',
'LoadoutManagerDialog', 'LoadoutManagerDialog',
'LoadoutConfig', 'LoadoutConfig',
'AttachmentConfig',
'WeaponSelectorDialog',
'ArmorSelectorDialog',
'AttachmentSelectorDialog',
'AttachmentManagerWidget',
] ]

678
ui/attachment_selector.py Normal file
View File

@ -0,0 +1,678 @@
"""
Attachment Selector Dialog for Lemontropia Suite
UI for selecting and managing gear attachments.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QDialogButtonBox, QFormLayout,
QTabWidget, QWidget
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from decimal import Decimal
from typing import Optional, List, Dict
from core.attachments import (
Attachment, WeaponAmplifier, WeaponScope, WeaponAbsorber,
ArmorPlating, FinderAmplifier, Enhancer, MindforceImplant,
get_mock_attachments
)
# ============================================================================
# Data Class for Attachment Configuration
# ============================================================================
class AttachmentConfig:
"""Configuration for an equipped attachment."""
def __init__(
self,
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: Optional[Dict[str, Decimal]] = None,
extra_data: Optional[Dict] = None
):
self.name = name
self.item_id = item_id
self.attachment_type = attachment_type
self.decay_pec = decay_pec
self.damage_bonus = damage_bonus
self.range_bonus = range_bonus
self.efficiency_bonus = efficiency_bonus
self.protection_bonus = protection_bonus or {}
self.extra_data = extra_data or {}
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
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()},
'extra_data': self.extra_data,
}
@classmethod
def from_dict(cls, data: dict) -> 'AttachmentConfig':
"""Create from dictionary."""
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()},
extra_data=data.get('extra_data', {}),
)
def get_cost_per_hour(self, uses_per_hour: int = 3600) -> Decimal:
"""Calculate cost per hour in PED."""
return (self.decay_pec * Decimal(uses_per_hour)) / Decimal("100")
def get_tooltip_text(self) -> str:
"""Get tooltip text describing the attachment."""
lines = [f"Decay: {self.decay_pec} PEC/shot"]
if self.damage_bonus > 0:
lines.append(f"Damage: +{self.damage_bonus}")
if self.range_bonus > 0:
lines.append(f"Range: +{self.range_bonus}m")
if self.efficiency_bonus > 0:
lines.append(f"Efficiency: +{self.efficiency_bonus}%")
if self.protection_bonus:
prots = [f"{k}: +{v}" for k, v in self.protection_bonus.items() if v > 0]
if prots:
lines.append("Protection: " + ", ".join(prots))
return "\n".join(lines)
# ============================================================================
# Attachment Selector Dialog
# ============================================================================
class AttachmentSelectorDialog(QDialog):
"""
Dialog for selecting attachments.
Supports all attachment types:
- amplifier: Weapon amplifiers (damage boost)
- scope: Weapon scopes (range boost)
- absorber: Weapon absorbers (damage reduction)
- plating: Armor plating (protection boost)
- finder_amp: Mining finder amplifiers
- enhancer: Gear enhancers
- implant: Mindforce implants
"""
attachment_selected = pyqtSignal(object) # AttachmentConfig or None
# Friendly names for attachment types
TYPE_NAMES = {
'amplifier': 'Weapon Amplifier',
'scope': 'Weapon Scope',
'absorber': 'Weapon Absorber',
'plating': 'Armor Plating',
'finder_amp': 'Finder Amplifier',
'enhancer': 'Enhancer',
'implant': 'Mindforce Implant',
}
# Icons for attachment types
TYPE_ICONS = {
'amplifier': '',
'scope': '🔭',
'absorber': '🛡️',
'plating': '🔩',
'finder_amp': '⛏️',
'enhancer': '',
'implant': '🧠',
}
def __init__(self, attachment_type: str, parent=None, allow_none: bool = True):
"""
Initialize attachment selector.
Args:
attachment_type: Type of attachment to select
parent: Parent widget
allow_none: Whether to allow selecting "None"
"""
super().__init__(parent)
self.attachment_type = attachment_type
self.selected_attachment = None
self.allow_none = allow_none
type_name = self.TYPE_NAMES.get(attachment_type, attachment_type.title())
self.setWindowTitle(f"Select {type_name}")
self.setMinimumSize(500, 450)
self._setup_ui()
self._load_attachments()
def _setup_ui(self):
"""Setup the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(10)
# Header
type_name = self.TYPE_NAMES.get(self.attachment_type, self.attachment_type.title())
type_icon = self.TYPE_ICONS.get(self.attachment_type, '📎')
header = QLabel(f"{type_icon} Select {type_name}")
header.setFont(QFont("Arial", 14, QFont.Weight.Bold))
header.setStyleSheet("color: #4a90d9; padding: 5px;")
layout.addWidget(header)
# Description
desc = self._get_type_description()
if desc:
desc_label = QLabel(desc)
desc_label.setStyleSheet("color: #888888; padding-bottom: 10px;")
desc_label.setWordWrap(True)
layout.addWidget(desc_label)
# Attachment list
self.list_widget = QListWidget()
self.list_widget.setAlternatingRowColors(True)
self.list_widget.setStyleSheet("""
QListWidget {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #3d3d3d;
border-radius: 4px;
}
QListWidget::item:selected {
background-color: #4a90d9;
}
QListWidget::item:hover {
background-color: #3d3d3d;
}
""")
self.list_widget.itemSelectionChanged.connect(self._on_selection_changed)
self.list_widget.itemDoubleClicked.connect(self._on_double_click)
layout.addWidget(self.list_widget)
# Stats preview
self.preview_group = QWidget()
preview_layout = QFormLayout(self.preview_group)
preview_layout.setContentsMargins(10, 10, 10, 10)
self.preview_group.setStyleSheet("""
QWidget {
background-color: #2d2d2d;
border-radius: 4px;
}
QLabel {
color: #e0e0e0;
}
""")
self.preview_name = QLabel("No attachment selected")
self.preview_name.setStyleSheet("font-weight: bold; color: #4caf50;")
self.preview_stats = QLabel("")
self.preview_stats.setStyleSheet("color: #888888;")
preview_layout.addRow(self.preview_name)
preview_layout.addRow(self.preview_stats)
layout.addWidget(self.preview_group)
# Buttons
button_layout = QHBoxLayout()
if self.allow_none:
self.none_btn = QPushButton("❌ Remove Attachment")
self.none_btn.setStyleSheet("""
QPushButton {
background-color: #7d2e2e;
color: #e0e0e0;
border: 1px solid #f44336;
border-radius: 4px;
padding: 8px 16px;
}
QPushButton:hover {
background-color: #f44336;
}
""")
self.none_btn.clicked.connect(self._on_none)
button_layout.addWidget(self.none_btn)
button_layout.addStretch()
self.ok_btn = QPushButton("✓ Select")
self.ok_btn.setEnabled(False)
self.ok_btn.setStyleSheet("""
QPushButton {
background-color: #2e7d32;
color: #e0e0e0;
border: 1px solid #4caf50;
border-radius: 4px;
padding: 8px 24px;
}
QPushButton:hover {
background-color: #4caf50;
}
QPushButton:disabled {
background-color: #3d3d3d;
border-color: #4d4d4d;
}
""")
self.ok_btn.clicked.connect(self._on_accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _get_type_description(self) -> str:
"""Get description for attachment type."""
descriptions = {
'amplifier': 'Amplifiers increase weapon damage at the cost of additional decay and ammo.',
'scope': 'Scopes increase weapon range for better accuracy at distance.',
'absorber': 'Absorbers reduce incoming damage when attached to weapons.',
'plating': 'Armor plating adds protection values to your armor set.',
'finder_amp': 'Finder amplifiers increase mining depth and radius.',
'enhancer': 'Enhancers add special effects to gear.',
'implant': 'Mindforce implants boost mindforce chip performance.',
}
return descriptions.get(self.attachment_type, "")
def _load_attachments(self):
"""Load attachments from data source."""
attachments = get_mock_attachments(self.attachment_type)
self.attachments = attachments
self.list_widget.clear()
for att in attachments:
icon = self.TYPE_ICONS.get(self.attachment_type, '📎')
item = QListWidgetItem(f"{icon} {att.name}")
item.setData(Qt.ItemDataRole.UserRole, att)
# Build tooltip
tooltip_lines = [f"Decay: {att.decay_pec} PEC/shot"]
if isinstance(att, WeaponAmplifier):
tooltip_lines.append(f"Damage: +{att.damage_increase}")
tooltip_lines.append(f"Ammo: +{att.ammo_increase}")
elif isinstance(att, WeaponScope):
tooltip_lines.append(f"Range: +{att.range_increase}m")
if att.accuracy_bonus > 0:
tooltip_lines.append(f"Accuracy: +{att.accuracy_bonus}")
elif isinstance(att, WeaponAbsorber):
tooltip_lines.append(f"Damage Reduction: {att.damage_reduction}")
elif isinstance(att, ArmorPlating):
tooltip_lines.append(f"Total Protection: +{att.get_total_protection()}")
# Add individual protections
prots = []
if att.protection_impact > 0:
prots.append(f"Impact +{att.protection_impact}")
if att.protection_cut > 0:
prots.append(f"Cut +{att.protection_cut}")
if att.protection_stab > 0:
prots.append(f"Stab +{att.protection_stab}")
if att.protection_burn > 0:
prots.append(f"Burn +{att.protection_burn}")
if prots:
tooltip_lines.append(", ".join(prots))
elif isinstance(att, FinderAmplifier):
tooltip_lines.append(f"Depth: +{att.depth_increase}m")
tooltip_lines.append(f"Radius: +{att.radius_increase}m")
elif isinstance(att, Enhancer):
tooltip_lines.append(f"Tier: {att.tier}")
tooltip_lines.append(f"Effect: {att.effect_name} +{att.effect_value}")
elif isinstance(att, MindforceImplant):
tooltip_lines.append(f"Mindforce Bonus: +{att.mindforce_bonus}")
item.setToolTip("\n".join(tooltip_lines))
self.list_widget.addItem(item)
def _on_selection_changed(self):
"""Handle selection change."""
selected = self.list_widget.selectedItems()
if selected:
attachment = selected[0].data(Qt.ItemDataRole.UserRole)
self.selected_attachment = attachment
self.ok_btn.setEnabled(True)
self._update_preview(attachment)
else:
self.selected_attachment = None
self.ok_btn.setEnabled(False)
self.preview_name.setText("No attachment selected")
self.preview_stats.setText("")
def _update_preview(self, attachment):
"""Update preview panel."""
self.preview_name.setText(attachment.name)
stats_lines = []
stats_lines.append(f"<b>Decay:</b> {attachment.decay_pec} PEC/shot")
if isinstance(attachment, WeaponAmplifier):
stats_lines.append(f"<b>Damage Increase:</b> +{attachment.damage_increase}")
stats_lines.append(f"<b>Ammo Increase:</b> +{attachment.ammo_increase}")
# Calculate DPP impact
if attachment.damage_increase > 0:
# Rough DPP calculation for the amp
amp_cost = attachment.decay_pec + (Decimal(attachment.ammo_increase) * Decimal("0.01"))
amp_dpp = attachment.damage_increase / amp_cost if amp_cost > 0 else Decimal("0")
stats_lines.append(f"<b>Amp DPP:</b> {amp_dpp:.2f}")
elif isinstance(attachment, WeaponScope):
stats_lines.append(f"<b>Range Increase:</b> +{attachment.range_increase}m")
if attachment.accuracy_bonus > 0:
stats_lines.append(f"<b>Accuracy Bonus:</b> +{attachment.accuracy_bonus}")
elif isinstance(attachment, WeaponAbsorber):
stats_lines.append(f"<b>Damage Reduction:</b> -{attachment.damage_reduction}")
elif isinstance(attachment, ArmorPlating):
total = attachment.get_total_protection()
stats_lines.append(f"<b>Total Protection:</b> +{total}")
# Individual protections
prots = []
if attachment.protection_impact > 0:
prots.append(f"Impact +{attachment.protection_impact}")
if attachment.protection_cut > 0:
prots.append(f"Cut +{attachment.protection_cut}")
if attachment.protection_stab > 0:
prots.append(f"Stab +{attachment.protection_stab}")
if attachment.protection_penetration > 0:
prots.append(f"Pen +{attachment.protection_penetration}")
if attachment.protection_burn > 0:
prots.append(f"Burn +{attachment.protection_burn}")
if attachment.protection_cold > 0:
prots.append(f"Cold +{attachment.protection_cold}")
if attachment.protection_acid > 0:
prots.append(f"Acid +{attachment.protection_acid}")
if attachment.protection_electric > 0:
prots.append(f"Elec +{attachment.protection_electric}")
if prots:
stats_lines.append(f"<b>Protection:</b> {', '.join(prots)}")
elif isinstance(attachment, FinderAmplifier):
stats_lines.append(f"<b>Depth:</b> +{attachment.depth_increase}m")
stats_lines.append(f"<b>Radius:</b> +{attachment.radius_increase}m")
elif isinstance(attachment, Enhancer):
stats_lines.append(f"<b>Tier:</b> {attachment.tier}")
stats_lines.append(f"<b>Effect:</b> {attachment.effect_name} +{attachment.effect_value}")
elif isinstance(attachment, MindforceImplant):
stats_lines.append(f"<b>Mindforce Bonus:</b> +{attachment.mindforce_bonus}")
# Calculate cost per hour (assuming 3600 uses/hour)
cost_per_hour = (attachment.decay_pec * Decimal("3600")) / Decimal("100")
stats_lines.append(f"<b>Cost/Hour:</b> {cost_per_hour:.2f} PED")
self.preview_stats.setText("<br>".join(stats_lines))
def _on_double_click(self, item):
"""Handle double click."""
self._on_accept()
def _on_accept(self):
"""Handle OK button."""
if self.selected_attachment:
att = self.selected_attachment
# Build protection bonus dict for armor plating
protection_bonus = {}
if isinstance(att, ArmorPlating):
protection_bonus = {
'stab': att.protection_stab,
'cut': att.protection_cut,
'impact': att.protection_impact,
'penetration': att.protection_penetration,
'shrapnel': att.protection_shrapnel,
'burn': att.protection_burn,
'cold': att.protection_cold,
'acid': att.protection_acid,
'electric': att.protection_electric,
}
# Build extra data based on type
extra_data = {}
if isinstance(att, WeaponAmplifier):
extra_data['ammo_increase'] = att.ammo_increase
elif isinstance(att, Enhancer):
extra_data['tier'] = att.tier
extra_data['effect_name'] = att.effect_name
extra_data['effect_value'] = float(att.effect_value)
config = AttachmentConfig(
name=att.name,
item_id=att.item_id,
attachment_type=att.attachment_type,
decay_pec=att.decay_pec,
damage_bonus=getattr(att, 'damage_increase', Decimal("0")),
range_bonus=getattr(att, 'range_increase', Decimal("0")),
efficiency_bonus=Decimal("0"),
protection_bonus=protection_bonus,
extra_data=extra_data,
)
self.attachment_selected.emit(config)
self.accept()
def _on_none(self):
"""Remove attachment."""
self.attachment_selected.emit(None)
self.accept()
@staticmethod
def select_attachment(attachment_type: str, parent=None, allow_none: bool = True) -> Optional[AttachmentConfig]:
"""
Static method to open selector and return selected attachment.
Args:
attachment_type: Type of attachment to select
parent: Parent widget
allow_none: Whether to allow selecting "None"
Returns:
AttachmentConfig or None if cancelled or "None" selected
"""
dialog = AttachmentSelectorDialog(attachment_type, parent, allow_none)
result = None
def on_selected(att):
nonlocal result
result = att
dialog.attachment_selected.connect(on_selected)
if dialog.exec() == QDialog.DialogCode.Accepted:
return result
return None
# ============================================================================
# Multi-Attachment Manager
# ============================================================================
class AttachmentManagerWidget(QWidget):
"""
Widget for managing multiple attachments on a piece of gear.
Shows currently equipped attachments and allows adding/removing.
"""
attachment_changed = pyqtSignal(str, object) # slot_name, AttachmentConfig or None
def __init__(self, gear_type: str = 'weapon', parent=None):
super().__init__(parent)
self.gear_type = gear_type
self.attachments: Dict[str, Optional[AttachmentConfig]] = {}
self._setup_ui()
def _setup_ui(self):
"""Setup the UI."""
from core.attachments import get_compatible_attachments
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
# Get compatible attachment types
compatible_types = get_compatible_attachments(self.gear_type)
for att_type in compatible_types:
type_name = AttachmentSelectorDialog.TYPE_NAMES.get(att_type, att_type.title())
type_icon = AttachmentSelectorDialog.TYPE_ICONS.get(att_type, '📎')
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
label = QLabel(f"{type_icon} {type_name}:")
label.setFixedWidth(120)
row_layout.addWidget(label)
status_label = QLabel("None")
status_label.setStyleSheet("color: #888888;")
row_layout.addWidget(status_label, stretch=1)
add_btn = QPushButton("+")
add_btn.setFixedSize(30, 24)
add_btn.setStyleSheet("""
QPushButton {
background-color: #2e7d32;
color: white;
border-radius: 4px;
}
QPushButton:hover {
background-color: #4caf50;
}
""")
add_btn.clicked.connect(lambda checked, t=att_type: self._on_add(t))
row_layout.addWidget(add_btn)
remove_btn = QPushButton("")
remove_btn.setFixedSize(30, 24)
remove_btn.setEnabled(False)
remove_btn.setStyleSheet("""
QPushButton {
background-color: #7d2e2e;
color: white;
border-radius: 4px;
}
QPushButton:hover {
background-color: #f44336;
}
QPushButton:disabled {
background-color: #3d3d3d;
}
""")
remove_btn.clicked.connect(lambda checked, t=att_type: self._on_remove(t))
row_layout.addWidget(remove_btn)
layout.addWidget(row)
# Store references
self.attachments[att_type] = None
setattr(self, f"{att_type}_status", status_label)
setattr(self, f"{att_type}_add_btn", add_btn)
setattr(self, f"{att_type}_remove_btn", remove_btn)
def _on_add(self, attachment_type: str):
"""Open selector for attachment type."""
dialog = AttachmentSelectorDialog(attachment_type, self, allow_none=False)
def on_selected(att):
if att:
self.attachments[attachment_type] = att
self._update_display(attachment_type, att)
self.attachment_changed.emit(attachment_type, att)
dialog.attachment_selected.connect(on_selected)
dialog.exec()
def _on_remove(self, attachment_type: str):
"""Remove attachment."""
self.attachments[attachment_type] = None
self._update_display(attachment_type, None)
self.attachment_changed.emit(attachment_type, None)
def _update_display(self, attachment_type: str, att: Optional[AttachmentConfig]):
"""Update display for attachment type."""
status_label = getattr(self, f"{attachment_type}_status")
remove_btn = getattr(self, f"{attachment_type}_remove_btn")
if att:
# Build status text
status_parts = [att.name]
if att.damage_bonus > 0:
status_parts.append(f"(+{att.damage_bonus} dmg)")
elif att.range_bonus > 0:
status_parts.append(f"(+{att.range_bonus}m)")
status_label.setText(" ".join(status_parts))
status_label.setStyleSheet("color: #4caf50;")
remove_btn.setEnabled(True)
else:
status_label.setText("None")
status_label.setStyleSheet("color: #888888;")
remove_btn.setEnabled(False)
def get_attachment(self, attachment_type: str) -> Optional[AttachmentConfig]:
"""Get attachment of given type."""
return self.attachments.get(attachment_type)
def set_attachment(self, attachment_type: str, att: Optional[AttachmentConfig]):
"""Set attachment of given type."""
if attachment_type in self.attachments:
self.attachments[attachment_type] = att
self._update_display(attachment_type, att)
def get_all_attachments(self) -> Dict[str, Optional[AttachmentConfig]]:
"""Get all attachments."""
return self.attachments.copy()
# ============================================================================
# Main entry point for testing
# ============================================================================
def main():
"""Test the attachment selector."""
import sys
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
app.setStyle('Fusion')
# Test single selector
print("Testing single attachment selector...")
result = AttachmentSelectorDialog.select_attachment('amplifier')
if result:
print(f"Selected: {result.name}")
print(f" Decay: {result.decay_pec} PEC")
print(f" Damage Bonus: {result.damage_bonus}")
else:
print("No attachment selected")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@ -56,21 +56,28 @@ class HUDStats:
# Financial tracking # Financial tracking
loot_total: Decimal = Decimal('0.0') loot_total: Decimal = Decimal('0.0')
cost_total: Decimal = Decimal('0.0') # Weapon decay + ammo cost_total: Decimal = Decimal('0.0') # Weapon decay + ammo
profit_loss: Decimal = Decimal('0.0') # loot - cost healing_cost_total: Decimal = Decimal('0.0') # NEW: Healing/FAP decay cost
profit_loss: Decimal = Decimal('0.0') # loot - cost - healing_cost
# Combat stats # Combat stats
damage_dealt: int = 0 damage_dealt: int = 0
damage_taken: int = 0 damage_taken: int = 0
shots_fired: int = 0 # NEW: Track shots fired shots_fired: int = 0
kills: int = 0 kills: int = 0
globals_count: int = 0 globals_count: int = 0
hofs_count: int = 0 hofs_count: int = 0
# Healing stats (NEW)
healing_done: Decimal = Decimal('0.0') # Total HP healed
heals_count: int = 0 # Number of heal actions
# Current gear # Current gear
current_weapon: str = "None" current_weapon: str = "None"
current_loadout: str = "None" current_loadout: str = "None"
current_medical_tool: str = "None" # NEW: Current FAP
weapon_dpp: Decimal = Decimal('0.0') weapon_dpp: Decimal = Decimal('0.0')
weapon_cost_per_hour: Decimal = Decimal('0.0') weapon_cost_per_hour: Decimal = Decimal('0.0')
medical_tool_decay: Decimal = Decimal('0.0') # NEW: FAP decay per use in PEC
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization.""" """Convert to dictionary for serialization."""
@ -78,6 +85,7 @@ class HUDStats:
'session_time_seconds': self.session_time.total_seconds(), 'session_time_seconds': self.session_time.total_seconds(),
'loot_total': str(self.loot_total), 'loot_total': str(self.loot_total),
'cost_total': str(self.cost_total), 'cost_total': str(self.cost_total),
'healing_cost_total': str(self.healing_cost_total),
'profit_loss': str(self.profit_loss), 'profit_loss': str(self.profit_loss),
'damage_dealt': self.damage_dealt, 'damage_dealt': self.damage_dealt,
'damage_taken': self.damage_taken, 'damage_taken': self.damage_taken,
@ -85,10 +93,14 @@ class HUDStats:
'kills': self.kills, 'kills': self.kills,
'globals_count': self.globals_count, 'globals_count': self.globals_count,
'hofs_count': self.hofs_count, 'hofs_count': self.hofs_count,
'healing_done': str(self.healing_done),
'heals_count': self.heals_count,
'current_weapon': self.current_weapon, 'current_weapon': self.current_weapon,
'current_loadout': self.current_loadout, 'current_loadout': self.current_loadout,
'current_medical_tool': self.current_medical_tool,
'weapon_dpp': str(self.weapon_dpp), 'weapon_dpp': str(self.weapon_dpp),
'weapon_cost_per_hour': str(self.weapon_cost_per_hour), 'weapon_cost_per_hour': str(self.weapon_cost_per_hour),
'medical_tool_decay': str(self.medical_tool_decay),
} }
@classmethod @classmethod
@ -98,6 +110,7 @@ class HUDStats:
session_time=timedelta(seconds=data.get('session_time_seconds', 0)), session_time=timedelta(seconds=data.get('session_time_seconds', 0)),
loot_total=Decimal(data.get('loot_total', '0.0')), loot_total=Decimal(data.get('loot_total', '0.0')),
cost_total=Decimal(data.get('cost_total', '0.0')), cost_total=Decimal(data.get('cost_total', '0.0')),
healing_cost_total=Decimal(data.get('healing_cost_total', '0.0')),
profit_loss=Decimal(data.get('profit_loss', '0.0')), profit_loss=Decimal(data.get('profit_loss', '0.0')),
damage_dealt=data.get('damage_dealt', 0), damage_dealt=data.get('damage_dealt', 0),
damage_taken=data.get('damage_taken', 0), damage_taken=data.get('damage_taken', 0),
@ -105,10 +118,14 @@ class HUDStats:
kills=data.get('kills', 0), kills=data.get('kills', 0),
globals_count=data.get('globals_count', 0), globals_count=data.get('globals_count', 0),
hofs_count=data.get('hofs_count', 0), hofs_count=data.get('hofs_count', 0),
healing_done=Decimal(data.get('healing_done', '0.0')),
heals_count=data.get('heals_count', 0),
current_weapon=data.get('current_weapon', 'None'), current_weapon=data.get('current_weapon', 'None'),
current_loadout=data.get('current_loadout', 'None'), current_loadout=data.get('current_loadout', 'None'),
current_medical_tool=data.get('current_medical_tool', 'None'),
weapon_dpp=Decimal(data.get('weapon_dpp', '0.0')), weapon_dpp=Decimal(data.get('weapon_dpp', '0.0')),
weapon_cost_per_hour=Decimal(data.get('weapon_cost_per_hour', '0.0')), weapon_cost_per_hour=Decimal(data.get('weapon_cost_per_hour', '0.0')),
medical_tool_decay=Decimal(data.get('medical_tool_decay', '0.0')),
) )
@ -202,17 +219,17 @@ class HUDOverlay(QWidget):
# Enable mouse tracking for hover detection # Enable mouse tracking for hover detection
self.setMouseTracking(True) self.setMouseTracking(True)
# Size # Size - increased to accommodate healing stats row
self.setFixedSize(320, 220) self.setFixedSize(320, 260)
# Accept focus for keyboard events (needed for modifier detection) # Accept focus for keyboard events (needed for modifier detection)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def _setup_ui(self) -> None: def _setup_ui(self) -> None:
"""Build the HUD UI components.""" """Build the HUD UI components."""
# Main container with semi-transparent background # Main container with semi-transparent background - increased height for healing row
self.container = QFrame(self) self.container = QFrame(self)
self.container.setFixedSize(320, 220) self.container.setFixedSize(320, 260)
self.container.setObjectName("hudContainer") self.container.setObjectName("hudContainer")
# Style the container - semi-transparent dark background # Style the container - semi-transparent dark background
@ -405,6 +422,50 @@ class HUDOverlay(QWidget):
layout.addLayout(row2) layout.addLayout(row2)
# Row 3: Healing Stats (NEW)
row3 = QHBoxLayout()
# Healing Done (HP)
healing_layout = QVBoxLayout()
healing_label = QLabel("❤️ HEALING")
healing_label.setStyleSheet("font-size: 10px; color: #888888;")
healing_layout.addWidget(healing_label)
self.healing_value_label = QLabel("0 HP")
self.healing_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #7FFF7F;")
healing_layout.addWidget(self.healing_value_label)
row3.addLayout(healing_layout)
row3.addStretch()
# Number of Heals
heals_layout = QVBoxLayout()
heals_label = QLabel("💉 HEALS")
heals_label.setStyleSheet("font-size: 10px; color: #888888;")
heals_layout.addWidget(heals_label)
self.heals_value_label = QLabel("0")
self.heals_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FFFFFF;")
heals_layout.addWidget(self.heals_value_label)
row3.addLayout(heals_layout)
row3.addStretch()
# Healing Cost
heal_cost_layout = QVBoxLayout()
heal_cost_label = QLabel("💊 HEAL COST")
heal_cost_label.setStyleSheet("font-size: 10px; color: #888888;")
heal_cost_layout.addWidget(heal_cost_label)
self.healing_cost_value_label = QLabel("0.00 PED")
self.healing_cost_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FF7F7F;")
heal_cost_layout.addWidget(self.healing_cost_value_label)
row3.addLayout(heal_cost_layout)
row3.addStretch()
layout.addLayout(row3)
# === WEAPON INFO === # === WEAPON INFO ===
weapon_separator = QFrame() weapon_separator = QFrame()
weapon_separator.setFrameShape(QFrame.Shape.HLine) weapon_separator.setFrameShape(QFrame.Shape.HLine)
@ -736,6 +797,32 @@ class HUDOverlay(QWidget):
self._refresh_display() self._refresh_display()
self.stats_updated.emit(self._stats.to_dict()) self.stats_updated.emit(self._stats.to_dict())
def on_heal_event(self, heal_amount: Decimal, decay_cost: Decimal = Decimal('0')) -> None:
"""Called when healing is done from LogWatcher.
Args:
heal_amount: Amount of HP healed
decay_cost: Cost of the heal in PED (based on FAP decay)
"""
if not self.session_active:
return
self._stats.healing_done += heal_amount
self._stats.heals_count += 1
# Add healing cost to total healing cost and update profit/loss
if decay_cost > 0:
self._stats.healing_cost_total += decay_cost
# Recalculate profit/loss including healing costs
self._stats.profit_loss = (
self._stats.loot_total -
self._stats.cost_total -
self._stats.healing_cost_total
)
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def update_display(self) -> None: def update_display(self) -> None:
"""Public method to refresh display (alias for _refresh_display).""" """Public method to refresh display (alias for _refresh_display)."""
self._refresh_display() self._refresh_display()
@ -850,6 +937,12 @@ class HUDOverlay(QWidget):
- 'damage_dealt_add': int - Add to damage dealt - 'damage_dealt_add': int - Add to damage dealt
- 'damage_taken': int - Total damage taken (or add) - 'damage_taken': int - Total damage taken (or add)
- 'damage_taken_add': int - Add to damage taken - 'damage_taken_add': int - Add to damage taken
- 'healing_done': Decimal - Total HP healed (or add)
- 'healing_add': Decimal - Add to healing done
- 'heals_count': int - Total number of heals (or add)
- 'heals_add': int - Add to heal count
- 'healing_cost': Decimal - Total healing cost in PED (or add)
- 'healing_cost_add': Decimal - Add to healing cost
- 'kills': int - Total kills (or add) - 'kills': int - Total kills (or add)
- 'kills_add': int - Add to kills - 'kills_add': int - Add to kills
- 'globals': int - Total globals (or add) - 'globals': int - Total globals (or add)
@ -858,6 +951,7 @@ class HUDOverlay(QWidget):
- 'hofs_add': int - Add to HoFs - 'hofs_add': int - Add to HoFs
- 'weapon': str - Current weapon name - 'weapon': str - Current weapon name
- 'loadout': str - Current loadout name - 'loadout': str - Current loadout name
- 'medical_tool': str - Current medical tool (FAP) name
""" """
# Loot (Decimal precision) # Loot (Decimal precision)
if 'loot' in stats: if 'loot' in stats:
@ -877,6 +971,24 @@ class HUDOverlay(QWidget):
elif 'damage_taken_add' in stats: elif 'damage_taken_add' in stats:
self._stats.damage_taken += int(stats['damage_taken_add']) self._stats.damage_taken += int(stats['damage_taken_add'])
# Healing done (NEW)
if 'healing_done' in stats:
self._stats.healing_done = Decimal(str(stats['healing_done']))
elif 'healing_add' in stats:
self._stats.healing_done += Decimal(str(stats['healing_add']))
# Healing count (NEW)
if 'heals_count' in stats:
self._stats.heals_count = int(stats['heals_count'])
elif 'heals_add' in stats:
self._stats.heals_count += int(stats['heals_add'])
# Healing cost (NEW)
if 'healing_cost' in stats:
self._stats.healing_cost_total = Decimal(str(stats['healing_cost']))
elif 'healing_cost_add' in stats:
self._stats.healing_cost_total += Decimal(str(stats['healing_cost_add']))
# Shots fired # Shots fired
if 'shots_fired' in stats: if 'shots_fired' in stats:
self._stats.shots_fired = int(stats['shots_fired']) self._stats.shots_fired = int(stats['shots_fired'])
@ -909,6 +1021,18 @@ class HUDOverlay(QWidget):
if 'loadout' in stats: if 'loadout' in stats:
self._stats.current_loadout = str(stats['loadout']) self._stats.current_loadout = str(stats['loadout'])
# Medical Tool (NEW)
if 'medical_tool' in stats:
self._stats.current_medical_tool = str(stats['medical_tool'])
# Recalculate profit/loss if costs changed
if any(k in stats for k in ['loot', 'loot_delta', 'healing_cost', 'healing_cost_add']):
self._stats.profit_loss = (
self._stats.loot_total -
self._stats.cost_total -
self._stats.healing_cost_total
)
# Refresh display # Refresh display
self._refresh_display() self._refresh_display()
@ -920,10 +1044,10 @@ class HUDOverlay(QWidget):
# Loot with 2 decimal places (PED format) # Loot with 2 decimal places (PED format)
self.loot_value_label.setText(f"{self._stats.loot_total:.2f} PED") self.loot_value_label.setText(f"{self._stats.loot_total:.2f} PED")
# Cost with 2 decimal places # Cost with 2 decimal places (weapon cost only)
self.cost_value_label.setText(f"{self._stats.cost_total:.2f} PED") self.cost_value_label.setText(f"{self._stats.cost_total:.2f} PED")
# Profit/Loss with color coding # Profit/Loss with color coding (includes healing costs)
profit = self._stats.profit_loss profit = self._stats.profit_loss
self.profit_value_label.setText(f"{profit:+.2f} PED") self.profit_value_label.setText(f"{profit:+.2f} PED")
if profit > 0: if profit > 0:
@ -948,6 +1072,11 @@ class HUDOverlay(QWidget):
self.dealt_value_label.setText(str(self._stats.damage_dealt)) self.dealt_value_label.setText(str(self._stats.damage_dealt))
self.taken_value_label.setText(str(self._stats.damage_taken)) self.taken_value_label.setText(str(self._stats.damage_taken))
# Healing Stats (NEW)
self.healing_value_label.setText(f"{self._stats.healing_done:.0f} HP")
self.heals_value_label.setText(str(self._stats.heals_count))
self.healing_cost_value_label.setText(f"{self._stats.healing_cost_total:.2f} PED")
# Weapon/Loadout/DPP # Weapon/Loadout/DPP
self.weapon_label.setText(self._stats.current_weapon[:20]) self.weapon_label.setText(self._stats.current_weapon[:20])
self.loadout_label.setText(self._stats.current_loadout[:15]) self.loadout_label.setText(self._stats.current_loadout[:15])

File diff suppressed because it is too large Load Diff

View File

@ -1028,9 +1028,21 @@ class MainWindow(QMainWindow):
on_damage_dealt(event) on_damage_dealt(event)
def on_damage_taken(event): def on_damage_taken(event):
"""Handle damage taken.""" """Handle damage taken - track armor decay cost."""
from decimal import Decimal
damage = event.data.get('damage', 0) damage = event.data.get('damage', 0)
self.hud.on_damage_taken(float(damage)) self.hud.on_damage_taken(float(damage))
# Calculate armor decay cost per hit
# Formula: cost_per_hit = armor_decay_pec / 100 (PED)
if self._selected_armor_stats and self._selected_armor_stats.get('decay'):
armor_decay_pec = Decimal(str(self._selected_armor_stats.get('decay', 0)))
if armor_decay_pec > 0:
# Convert PEC to PED (1 PED = 100 PEC)
cost_ped = armor_decay_pec / Decimal('100')
self.hud.update_cost(cost_ped)
self.log_debug("Armor", f"Armor decay: {cost_ped:.4f} PED (decay: {armor_decay_pec} PEC)")
def on_evade(event): def on_evade(event):
"""Handle evade.""" """Handle evade."""