1793 lines
67 KiB
Python
1793 lines
67 KiB
Python
"""
|
||
Lemontropia Suite - Main Application Window
|
||
PyQt6 GUI for managing game automation projects and sessions.
|
||
"""
|
||
|
||
import sys
|
||
import logging
|
||
from datetime import datetime
|
||
from enum import Enum, auto
|
||
from typing import Optional, List, Callable
|
||
from dataclasses import dataclass
|
||
|
||
# Setup logger
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QSplitter, QPushButton, QListWidget, QListWidgetItem,
|
||
QTextEdit, QLabel, QStatusBar, QMenuBar, QMenu,
|
||
QDialog, QLineEdit, QFormLayout, QDialogButtonBox,
|
||
QMessageBox, QGroupBox, QFrame, QApplication,
|
||
QTreeWidget, QTreeWidgetItem, QHeaderView
|
||
)
|
||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QSize, QSettings
|
||
from PyQt6.QtGui import QAction, QFont, QColor, QPalette, QIcon
|
||
|
||
|
||
# ============================================================================
|
||
# Data Models
|
||
# ============================================================================
|
||
|
||
class SessionState(Enum):
|
||
"""Session state enumeration."""
|
||
IDLE = "Idle"
|
||
RUNNING = "Running"
|
||
PAUSED = "Paused"
|
||
ERROR = "Error"
|
||
STOPPING = "Stopping"
|
||
|
||
|
||
@dataclass
|
||
class Project:
|
||
"""Project data model."""
|
||
id: int
|
||
name: str
|
||
description: str = ""
|
||
created_at: Optional[datetime] = None
|
||
session_count: int = 0
|
||
last_session: Optional[datetime] = None
|
||
|
||
|
||
@dataclass
|
||
class LogEvent:
|
||
"""Log event data model."""
|
||
timestamp: datetime
|
||
level: str # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||
source: str
|
||
message: str
|
||
|
||
def __str__(self) -> str:
|
||
time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3]
|
||
return f"[{time_str}] [{self.level}] [{self.source}] {self.message}"
|
||
|
||
|
||
# ============================================================================
|
||
# HUD Overlay
|
||
# ============================================================================
|
||
|
||
from ui.hud_overlay_clean import HUDOverlay
|
||
|
||
|
||
# ============================================================================
|
||
# Core Integration
|
||
# ============================================================================
|
||
|
||
import os
|
||
import asyncio
|
||
from pathlib import Path
|
||
from decimal import Decimal
|
||
|
||
# Add core to path for imports
|
||
sys.path.insert(0, str(Path(__file__).parent.parent / "core"))
|
||
|
||
from core.log_watcher import LogWatcher
|
||
from core.project_manager import ProjectManager
|
||
from core.database import DatabaseManager
|
||
|
||
|
||
# ============================================================================
|
||
# Custom Dialogs
|
||
# ============================================================================
|
||
|
||
class NewProjectDialog(QDialog):
|
||
"""Dialog for creating a new project."""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("New Project")
|
||
self.setMinimumWidth(400)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Form layout for inputs
|
||
form_layout = QFormLayout()
|
||
|
||
self.name_input = QLineEdit()
|
||
self.name_input.setPlaceholderText("Enter project name...")
|
||
form_layout.addRow("Name:", self.name_input)
|
||
|
||
self.desc_input = QLineEdit()
|
||
self.desc_input.setPlaceholderText("Enter description (optional)...")
|
||
form_layout.addRow("Description:", self.desc_input)
|
||
|
||
layout.addLayout(form_layout)
|
||
layout.addSpacing(10)
|
||
|
||
# Button box
|
||
button_box = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
button_box.accepted.connect(self.accept)
|
||
button_box.rejected.connect(self.reject)
|
||
layout.addWidget(button_box)
|
||
|
||
def get_project_data(self) -> tuple:
|
||
"""Get the entered project data."""
|
||
return self.name_input.text().strip(), self.desc_input.text().strip()
|
||
|
||
def accept(self):
|
||
"""Validate before accepting."""
|
||
name = self.name_input.text().strip()
|
||
if not name:
|
||
QMessageBox.warning(self, "Validation Error", "Project name is required.")
|
||
return
|
||
super().accept()
|
||
|
||
|
||
class ProjectStatsDialog(QDialog):
|
||
"""Dialog for displaying project statistics."""
|
||
|
||
def __init__(self, project: Project, parent=None):
|
||
super().__init__(parent)
|
||
self.project = project
|
||
self.setWindowTitle(f"Project Statistics - {project.name}")
|
||
self.setMinimumWidth(350)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Stats display
|
||
stats_group = QGroupBox("Project Information")
|
||
stats_layout = QFormLayout(stats_group)
|
||
|
||
stats_layout.addRow("ID:", QLabel(str(self.project.id)))
|
||
stats_layout.addRow("Name:", QLabel(self.project.name))
|
||
stats_layout.addRow("Type:", QLabel(self.project.type))
|
||
stats_layout.addRow("Status:", QLabel(self.project.status))
|
||
|
||
# Description from metadata
|
||
description = self.project.metadata.get('description', 'N/A') if self.project.metadata else 'N/A'
|
||
stats_layout.addRow("Description:", QLabel(description))
|
||
|
||
created = self.project.created_at.strftime("%Y-%m-%d %H:%M") if self.project.created_at else "N/A"
|
||
stats_layout.addRow("Created:", QLabel(created))
|
||
|
||
layout.addWidget(stats_group)
|
||
|
||
# Close button
|
||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||
button_box.rejected.connect(self.reject)
|
||
layout.addWidget(button_box)
|
||
|
||
|
||
class SettingsDialog(QDialog):
|
||
"""Dialog for application settings."""
|
||
|
||
def __init__(self, parent=None, current_player_name: str = ""):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Settings")
|
||
self.setMinimumWidth(400)
|
||
self.player_name = current_player_name
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Player Settings Group
|
||
player_group = QGroupBox("Player Settings")
|
||
player_layout = QFormLayout(player_group)
|
||
|
||
self.player_name_edit = QLineEdit()
|
||
self.player_name_edit.setText(self.player_name)
|
||
self.player_name_edit.setPlaceholderText("Your avatar name in Entropia Universe")
|
||
player_layout.addRow("Avatar Name:", self.player_name_edit)
|
||
|
||
help_label = QLabel("Set your avatar name to track your globals correctly.")
|
||
help_label.setStyleSheet("color: #888; font-size: 11px;")
|
||
player_layout.addRow(help_label)
|
||
|
||
layout.addWidget(player_group)
|
||
layout.addStretch()
|
||
|
||
button_box = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
button_box.accepted.connect(self.accept)
|
||
button_box.rejected.connect(self.reject)
|
||
layout.addWidget(button_box)
|
||
|
||
def get_player_name(self) -> str:
|
||
"""Get the configured player name."""
|
||
return self.player_name_edit.text().strip()
|
||
|
||
|
||
# ============================================================================
|
||
# Main Window
|
||
# ============================================================================
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""
|
||
Main application window for Lemontropia Suite.
|
||
|
||
Provides project management, session control, and log viewing capabilities.
|
||
"""
|
||
|
||
# Signals
|
||
session_started = pyqtSignal(int) # project_id
|
||
session_stopped = pyqtSignal()
|
||
session_paused = pyqtSignal()
|
||
session_resumed = pyqtSignal()
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
|
||
# Window configuration
|
||
self.setWindowTitle("Lemontropia Suite")
|
||
self.setMinimumSize(1200, 800)
|
||
self.resize(1400, 900)
|
||
|
||
# Initialize database and real project manager
|
||
self.db = DatabaseManager()
|
||
if not self.db.initialize():
|
||
QMessageBox.critical(self, "Error", "Failed to initialize database!")
|
||
sys.exit(1)
|
||
|
||
self.project_manager = ProjectManager(self.db)
|
||
|
||
# Initialize HUD
|
||
self.hud = HUDOverlay()
|
||
|
||
# Log watcher - created when session starts
|
||
self.log_watcher: Optional[LogWatcher] = None
|
||
self._log_watcher_task = None
|
||
|
||
# Thread-safe queue for cross-thread communication
|
||
from queue import Queue
|
||
self._event_queue = Queue()
|
||
|
||
# Timer to process queued events in main thread
|
||
self._queue_timer = QTimer(self)
|
||
self._queue_timer.timeout.connect(self._process_queued_events)
|
||
self._queue_timer.start(100) # Check every 100ms
|
||
|
||
# State
|
||
self.current_project: Optional[Project] = None
|
||
self.session_state = SessionState.IDLE
|
||
self.current_session_id: Optional[int] = None
|
||
self._current_db_session_id: Optional[int] = None
|
||
|
||
# Player settings
|
||
self.player_name: str = "" # Set via Settings dialog
|
||
|
||
# Load persistent settings
|
||
self._load_settings()
|
||
|
||
# Selected gear
|
||
self._selected_weapon: Optional[str] = None
|
||
self._selected_weapon_stats: Optional[dict] = None
|
||
self._selected_armor: Optional[str] = None
|
||
self._selected_armor_stats: Optional[dict] = None
|
||
self._selected_finder: Optional[str] = None
|
||
self._selected_finder_stats: Optional[dict] = None
|
||
self._selected_medical_tool: Optional[str] = None
|
||
self._selected_medical_tool_stats: Optional[dict] = None
|
||
self._selected_loadout: Optional[Any] = None
|
||
|
||
# Session cost tracking (initialized empty, populated by loadout selection)
|
||
self._session_costs: dict = {
|
||
'cost_per_shot': Decimal('0'),
|
||
'cost_per_hit': Decimal('0'),
|
||
'cost_per_heal': Decimal('0'),
|
||
}
|
||
self._session_display: dict = {
|
||
'weapon_name': 'None',
|
||
'armor_name': 'None',
|
||
'healing_name': 'None',
|
||
}
|
||
|
||
# Setup UI
|
||
self.setup_ui()
|
||
self.apply_dark_theme()
|
||
self.create_menu_bar()
|
||
self.create_status_bar()
|
||
|
||
# Load initial data
|
||
self.refresh_project_list()
|
||
|
||
# Welcome message
|
||
self.log_info("Application", "Lemontropia Suite initialized")
|
||
self.log_info("Database", f"Database ready: {self.db.db_path}")
|
||
|
||
# ========================================================================
|
||
# UI Setup
|
||
# ========================================================================
|
||
|
||
def setup_ui(self):
|
||
"""Setup the main UI layout."""
|
||
# Central widget
|
||
central_widget = QWidget()
|
||
self.setCentralWidget(central_widget)
|
||
|
||
# Main layout
|
||
main_layout = QVBoxLayout(central_widget)
|
||
main_layout.setContentsMargins(10, 10, 10, 10)
|
||
main_layout.setSpacing(10)
|
||
|
||
# Main splitter (horizontal: left panels | log panel)
|
||
self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
main_layout.addWidget(self.main_splitter)
|
||
|
||
# Left side container
|
||
left_container = QWidget()
|
||
left_layout = QVBoxLayout(left_container)
|
||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||
left_layout.setSpacing(10)
|
||
|
||
# Left splitter (vertical: projects | session control)
|
||
left_splitter = QSplitter(Qt.Orientation.Vertical)
|
||
left_layout.addWidget(left_splitter)
|
||
|
||
# Project panel
|
||
self.project_panel = self.create_project_panel()
|
||
left_splitter.addWidget(self.project_panel)
|
||
|
||
# Session control panel
|
||
self.session_panel = self.create_session_panel()
|
||
left_splitter.addWidget(self.session_panel)
|
||
|
||
# Set splitter proportions
|
||
left_splitter.setSizes([400, 300])
|
||
|
||
# Add left container to main splitter
|
||
self.main_splitter.addWidget(left_container)
|
||
|
||
# Log output panel
|
||
self.log_panel = self.create_log_panel()
|
||
self.main_splitter.addWidget(self.log_panel)
|
||
|
||
# Set main splitter proportions (30% left, 70% log)
|
||
self.main_splitter.setSizes([400, 900])
|
||
|
||
def create_project_panel(self) -> QGroupBox:
|
||
"""Create the project management panel."""
|
||
panel = QGroupBox("Project Management")
|
||
layout = QVBoxLayout(panel)
|
||
layout.setSpacing(8)
|
||
|
||
# Project list
|
||
self.project_list = QTreeWidget()
|
||
self.project_list.setHeaderLabels(["ID", "Name", "Type", "Status"])
|
||
self.project_list.setAlternatingRowColors(True)
|
||
self.project_list.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection)
|
||
self.project_list.setRootIsDecorated(False)
|
||
self.project_list.itemSelectionChanged.connect(self.on_project_selected)
|
||
self.project_list.itemDoubleClicked.connect(self.on_project_double_clicked)
|
||
|
||
# Adjust column widths
|
||
header = self.project_list.header()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
||
header.resizeSection(0, 50)
|
||
header.resizeSection(2, 70)
|
||
header.resizeSection(3, 80)
|
||
|
||
layout.addWidget(self.project_list)
|
||
|
||
# Button row
|
||
button_layout = QHBoxLayout()
|
||
|
||
self.new_project_btn = QPushButton("➕ New Project")
|
||
self.new_project_btn.setToolTip("Create a new project")
|
||
self.new_project_btn.clicked.connect(self.on_new_project)
|
||
button_layout.addWidget(self.new_project_btn)
|
||
|
||
self.view_stats_btn = QPushButton("📊 View Stats")
|
||
self.view_stats_btn.setToolTip("View selected project statistics")
|
||
self.view_stats_btn.clicked.connect(self.on_view_stats)
|
||
self.view_stats_btn.setEnabled(False)
|
||
button_layout.addWidget(self.view_stats_btn)
|
||
|
||
self.refresh_btn = QPushButton("🔄 Refresh")
|
||
self.refresh_btn.setToolTip("Refresh project list")
|
||
self.refresh_btn.clicked.connect(self.refresh_project_list)
|
||
button_layout.addWidget(self.refresh_btn)
|
||
|
||
layout.addLayout(button_layout)
|
||
|
||
return panel
|
||
|
||
def create_session_panel(self) -> QGroupBox:
|
||
"""Create the session control panel."""
|
||
panel = QGroupBox("Session Control")
|
||
layout = QVBoxLayout(panel)
|
||
layout.setSpacing(10)
|
||
|
||
# Current project display
|
||
project_info_layout = QFormLayout()
|
||
self.current_project_label = QLabel("No project selected")
|
||
self.current_project_label.setStyleSheet("font-weight: bold; color: #888;")
|
||
project_info_layout.addRow("Selected Project:", self.current_project_label)
|
||
layout.addLayout(project_info_layout)
|
||
|
||
# Separator line
|
||
separator = QFrame()
|
||
separator.setFrameShape(QFrame.Shape.HLine)
|
||
separator.setStyleSheet("background-color: #444;")
|
||
layout.addWidget(separator)
|
||
|
||
# Session status
|
||
status_layout = QHBoxLayout()
|
||
status_layout.addWidget(QLabel("Status:"))
|
||
self.session_status_label = QLabel("Idle")
|
||
self.session_status_label.setStyleSheet("""
|
||
QLabel {
|
||
font-weight: bold;
|
||
color: #888;
|
||
padding: 5px 15px;
|
||
background-color: #2a2a2a;
|
||
border-radius: 4px;
|
||
border: 1px solid #444;
|
||
}
|
||
""")
|
||
status_layout.addWidget(self.session_status_label)
|
||
status_layout.addStretch()
|
||
layout.addLayout(status_layout)
|
||
|
||
# Control buttons
|
||
button_layout = QHBoxLayout()
|
||
|
||
self.start_session_btn = QPushButton("▶️ Start Session")
|
||
self.start_session_btn.setToolTip("Start a new session with selected project")
|
||
self.start_session_btn.clicked.connect(self.on_start_session)
|
||
self.start_session_btn.setEnabled(False)
|
||
button_layout.addWidget(self.start_session_btn)
|
||
|
||
self.stop_session_btn = QPushButton("⏹️ Stop")
|
||
self.stop_session_btn.setToolTip("Stop current session")
|
||
self.stop_session_btn.clicked.connect(self.on_stop_session)
|
||
self.stop_session_btn.setEnabled(False)
|
||
button_layout.addWidget(self.stop_session_btn)
|
||
|
||
self.pause_session_btn = QPushButton("⏸️ Pause")
|
||
self.pause_session_btn.setToolTip("Pause/Resume current session")
|
||
self.pause_session_btn.clicked.connect(self.on_pause_session)
|
||
self.pause_session_btn.setEnabled(False)
|
||
button_layout.addWidget(self.pause_session_btn)
|
||
|
||
layout.addLayout(button_layout)
|
||
|
||
# Session info
|
||
self.session_info_label = QLabel("Ready to start")
|
||
self.session_info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.session_info_label.setStyleSheet("color: #666; padding: 10px;")
|
||
layout.addWidget(self.session_info_label)
|
||
|
||
layout.addStretch()
|
||
|
||
return panel
|
||
|
||
def create_log_panel(self) -> QGroupBox:
|
||
"""Create the log output panel."""
|
||
panel = QGroupBox("Event Log")
|
||
layout = QVBoxLayout(panel)
|
||
layout.setSpacing(8)
|
||
|
||
# Log text edit
|
||
self.log_output = QTextEdit()
|
||
self.log_output.setReadOnly(True)
|
||
self.log_output.setFont(QFont("Consolas", 10))
|
||
self.log_output.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
||
layout.addWidget(self.log_output)
|
||
|
||
# Log controls
|
||
controls_layout = QHBoxLayout()
|
||
|
||
self.clear_log_btn = QPushButton("🗑️ Clear")
|
||
self.clear_log_btn.setToolTip("Clear log output")
|
||
self.clear_log_btn.clicked.connect(self.log_output.clear)
|
||
controls_layout.addWidget(self.clear_log_btn)
|
||
|
||
controls_layout.addStretch()
|
||
|
||
self.auto_scroll_check = QLabel("✓ Auto-scroll")
|
||
self.auto_scroll_check.setStyleSheet("color: #888;")
|
||
controls_layout.addWidget(self.auto_scroll_check)
|
||
|
||
layout.addLayout(controls_layout)
|
||
|
||
return panel
|
||
|
||
def create_menu_bar(self):
|
||
"""Create the application menu bar."""
|
||
menubar = self.menuBar()
|
||
|
||
# File menu
|
||
file_menu = menubar.addMenu("&File")
|
||
|
||
new_project_action = QAction("&New Project", self)
|
||
new_project_action.setShortcut("Ctrl+N")
|
||
new_project_action.triggered.connect(self.on_new_project)
|
||
file_menu.addAction(new_project_action)
|
||
|
||
open_project_action = QAction("&Open Project", self)
|
||
open_project_action.setShortcut("Ctrl+O")
|
||
open_project_action.triggered.connect(self.on_open_project)
|
||
file_menu.addAction(open_project_action)
|
||
|
||
file_menu.addSeparator()
|
||
|
||
exit_action = QAction("E&xit", self)
|
||
exit_action.setShortcut("Alt+F4")
|
||
exit_action.triggered.connect(self.close)
|
||
file_menu.addAction(exit_action)
|
||
|
||
# Session menu
|
||
session_menu = menubar.addMenu("&Session")
|
||
|
||
start_action = QAction("&Start", self)
|
||
start_action.setShortcut("F5")
|
||
start_action.triggered.connect(self.on_start_session)
|
||
session_menu.addAction(start_action)
|
||
self.start_action = start_action
|
||
|
||
stop_action = QAction("St&op", self)
|
||
stop_action.setShortcut("Shift+F5")
|
||
stop_action.triggered.connect(self.on_stop_session)
|
||
session_menu.addAction(stop_action)
|
||
self.stop_action = stop_action
|
||
|
||
pause_action = QAction("&Pause", self)
|
||
pause_action.setShortcut("F6")
|
||
pause_action.triggered.connect(self.on_pause_session)
|
||
session_menu.addAction(pause_action)
|
||
self.pause_action = pause_action
|
||
|
||
# View menu
|
||
view_menu = menubar.addMenu("&View")
|
||
|
||
show_hud_action = QAction("Show &HUD", self)
|
||
show_hud_action.setShortcut("F9")
|
||
show_hud_action.triggered.connect(self.on_show_hud)
|
||
view_menu.addAction(show_hud_action)
|
||
|
||
hide_hud_action = QAction("&Hide HUD", self)
|
||
hide_hud_action.setShortcut("F10")
|
||
hide_hud_action.triggered.connect(self.on_hide_hud)
|
||
view_menu.addAction(hide_hud_action)
|
||
|
||
view_menu.addSeparator()
|
||
|
||
settings_action = QAction("&Settings", self)
|
||
settings_action.setShortcut("Ctrl+,")
|
||
settings_action.triggered.connect(self.on_settings)
|
||
view_menu.addAction(settings_action)
|
||
|
||
# Tools menu
|
||
tools_menu = menubar.addMenu("&Tools")
|
||
|
||
# Select Gear submenu
|
||
select_gear_menu = tools_menu.addMenu("Select &Gear")
|
||
|
||
select_weapon_action = QAction("&Weapon", self)
|
||
select_weapon_action.setShortcut("Ctrl+W")
|
||
select_weapon_action.triggered.connect(lambda: self.on_select_gear("weapon"))
|
||
select_gear_menu.addAction(select_weapon_action)
|
||
|
||
select_armor_action = QAction("&Armor", self)
|
||
select_armor_action.setShortcut("Ctrl+Shift+A")
|
||
select_armor_action.triggered.connect(lambda: self.on_select_gear("armor"))
|
||
select_gear_menu.addAction(select_armor_action)
|
||
|
||
select_finder_action = QAction("&Finder", self)
|
||
select_finder_action.setShortcut("Ctrl+Shift+F")
|
||
select_finder_action.triggered.connect(lambda: self.on_select_gear("finder"))
|
||
select_gear_menu.addAction(select_finder_action)
|
||
|
||
select_medical_action = QAction("&Medical Tool", self)
|
||
select_medical_action.setShortcut("Ctrl+M")
|
||
select_medical_action.triggered.connect(lambda: self.on_select_gear("medical_tool"))
|
||
select_gear_menu.addAction(select_medical_action)
|
||
|
||
tools_menu.addSeparator()
|
||
|
||
loadout_action = QAction("&Loadout Manager", self)
|
||
loadout_action.setShortcut("Ctrl+L")
|
||
loadout_action.triggered.connect(self.on_loadout_manager)
|
||
tools_menu.addAction(loadout_action)
|
||
|
||
# Help menu
|
||
help_menu = menubar.addMenu("&Help")
|
||
|
||
about_action = QAction("&About", self)
|
||
about_action.triggered.connect(self.on_about)
|
||
help_menu.addAction(about_action)
|
||
|
||
def create_status_bar(self):
|
||
"""Create the status bar."""
|
||
self.status_bar = QStatusBar()
|
||
self.setStatusBar(self.status_bar)
|
||
|
||
# Permanent widgets
|
||
self.status_state_label = QLabel("● Idle")
|
||
self.status_state_label.setStyleSheet("color: #888; padding: 0 10px;")
|
||
self.status_bar.addPermanentWidget(self.status_state_label)
|
||
|
||
self.status_project_label = QLabel("No project")
|
||
self.status_project_label.setStyleSheet("color: #888; padding: 0 10px;")
|
||
self.status_bar.addPermanentWidget(self.status_project_label)
|
||
|
||
# Message area
|
||
self.status_bar.showMessage("Ready")
|
||
|
||
# ========================================================================
|
||
# Theme
|
||
# ========================================================================
|
||
|
||
def apply_dark_theme(self):
|
||
"""Apply dark theme styling."""
|
||
dark_stylesheet = """
|
||
QMainWindow {
|
||
background-color: #1e1e1e;
|
||
}
|
||
|
||
QWidget {
|
||
background-color: #1e1e1e;
|
||
color: #e0e0e0;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
font-size: 10pt;
|
||
}
|
||
|
||
QGroupBox {
|
||
font-weight: bold;
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
padding: 10px;
|
||
}
|
||
|
||
QGroupBox::title {
|
||
subcontrol-origin: margin;
|
||
left: 10px;
|
||
padding: 0 5px;
|
||
color: #888;
|
||
}
|
||
|
||
QPushButton {
|
||
background-color: #2d2d2d;
|
||
border: 1px solid #444;
|
||
border-radius: 4px;
|
||
padding: 8px 16px;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
QPushButton:hover {
|
||
background-color: #3d3d3d;
|
||
border-color: #555;
|
||
}
|
||
|
||
QPushButton:pressed {
|
||
background-color: #4d4d4d;
|
||
}
|
||
|
||
QPushButton:disabled {
|
||
background-color: #252525;
|
||
color: #666;
|
||
border-color: #333;
|
||
}
|
||
|
||
QPushButton#start_button {
|
||
background-color: #1b5e20;
|
||
border-color: #2e7d32;
|
||
}
|
||
|
||
QPushButton#start_button:hover {
|
||
background-color: #2e7d32;
|
||
}
|
||
|
||
QPushButton#stop_button {
|
||
background-color: #b71c1c;
|
||
border-color: #c62828;
|
||
}
|
||
|
||
QPushButton#stop_button:hover {
|
||
background-color: #c62828;
|
||
}
|
||
|
||
QTreeWidget {
|
||
background-color: #252525;
|
||
border: 1px solid #444;
|
||
border-radius: 4px;
|
||
outline: none;
|
||
}
|
||
|
||
QTreeWidget::item {
|
||
padding: 6px;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
|
||
QTreeWidget::item:selected {
|
||
background-color: #0d47a1;
|
||
color: white;
|
||
}
|
||
|
||
QTreeWidget::item:alternate {
|
||
background-color: #2a2a2a;
|
||
}
|
||
|
||
QHeaderView::section {
|
||
background-color: #2d2d2d;
|
||
padding: 6px;
|
||
border: none;
|
||
border-right: 1px solid #444;
|
||
font-weight: bold;
|
||
}
|
||
|
||
QTextEdit {
|
||
background-color: #151515;
|
||
border: 1px solid #444;
|
||
border-radius: 4px;
|
||
padding: 8px;
|
||
color: #d0d0d0;
|
||
}
|
||
|
||
QLineEdit {
|
||
background-color: #252525;
|
||
border: 1px solid #444;
|
||
border-radius: 4px;
|
||
padding: 6px;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
QLineEdit:focus {
|
||
border-color: #0d47a1;
|
||
}
|
||
|
||
QMenuBar {
|
||
background-color: #1e1e1e;
|
||
border-bottom: 1px solid #444;
|
||
}
|
||
|
||
QMenuBar::item {
|
||
background-color: transparent;
|
||
padding: 6px 12px;
|
||
}
|
||
|
||
QMenuBar::item:selected {
|
||
background-color: #2d2d2d;
|
||
}
|
||
|
||
QMenu {
|
||
background-color: #2d2d2d;
|
||
border: 1px solid #444;
|
||
padding: 4px;
|
||
}
|
||
|
||
QMenu::item {
|
||
padding: 6px 24px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
QMenu::item:selected {
|
||
background-color: #0d47a1;
|
||
}
|
||
|
||
QMenu::separator {
|
||
height: 1px;
|
||
background-color: #444;
|
||
margin: 4px 8px;
|
||
}
|
||
|
||
QStatusBar {
|
||
background-color: #1e1e1e;
|
||
border-top: 1px solid #444;
|
||
}
|
||
|
||
QSplitter::handle {
|
||
background-color: #444;
|
||
}
|
||
|
||
QSplitter::handle:horizontal {
|
||
width: 2px;
|
||
}
|
||
|
||
QSplitter::handle:vertical {
|
||
height: 2px;
|
||
}
|
||
|
||
QDialog {
|
||
background-color: #1e1e1e;
|
||
}
|
||
|
||
QLabel {
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
QFormLayout QLabel {
|
||
color: #888;
|
||
}
|
||
"""
|
||
self.setStyleSheet(dark_stylesheet)
|
||
|
||
# ========================================================================
|
||
# Project Management
|
||
# ========================================================================
|
||
|
||
def refresh_project_list(self):
|
||
"""Refresh the project list display."""
|
||
self.project_list.clear()
|
||
projects = self.project_manager.list_projects()
|
||
|
||
for project in projects:
|
||
item = QTreeWidgetItem([
|
||
str(project.id),
|
||
project.name,
|
||
project.type,
|
||
project.status
|
||
])
|
||
item.setData(0, Qt.ItemDataRole.UserRole, project.id)
|
||
self.project_list.addTopLevelItem(item)
|
||
|
||
self.log_debug("ProjectManager", f"Loaded {len(projects)} projects")
|
||
|
||
def on_project_selected(self):
|
||
"""Handle project selection change."""
|
||
selected = self.project_list.selectedItems()
|
||
if selected:
|
||
project_id = selected[0].data(0, Qt.ItemDataRole.UserRole)
|
||
self.current_project = self.project_manager.load_project(project_id)
|
||
|
||
if self.current_project:
|
||
self.current_project_label.setText(self.current_project.name)
|
||
self.current_project_label.setStyleSheet("font-weight: bold; color: #4caf50;")
|
||
self.view_stats_btn.setEnabled(True)
|
||
self.start_session_btn.setEnabled(self.session_state == SessionState.IDLE)
|
||
self.status_project_label.setText(f"Project: {self.current_project.name}")
|
||
self.log_debug("ProjectManager", f"Selected project: {self.current_project.name}")
|
||
else:
|
||
self.current_project = None
|
||
self.current_project_label.setText("No project selected")
|
||
self.current_project_label.setStyleSheet("font-weight: bold; color: #888;")
|
||
self.view_stats_btn.setEnabled(False)
|
||
self.start_session_btn.setEnabled(False)
|
||
self.status_project_label.setText("No project")
|
||
|
||
def on_project_double_clicked(self, item: QTreeWidgetItem, column: int):
|
||
"""Handle double-click on project."""
|
||
project_id = item.data(0, Qt.ItemDataRole.UserRole)
|
||
project = self.project_manager.load_project(project_id)
|
||
if project:
|
||
self.show_project_stats(project)
|
||
|
||
def on_new_project(self):
|
||
"""Handle new project creation."""
|
||
dialog = NewProjectDialog(self)
|
||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||
name, description = dialog.get_project_data()
|
||
metadata = {"description": description} if description else None
|
||
project = self.project_manager.create_project(name, 'hunt', metadata)
|
||
self.refresh_project_list()
|
||
self.log_info("ProjectManager", f"Created project: {project.name}")
|
||
self.status_bar.showMessage(f"Project '{name}' created", 3000)
|
||
|
||
def on_open_project(self):
|
||
"""Handle open project action."""
|
||
# For now, just focus the project list
|
||
self.project_list.setFocus()
|
||
self.status_bar.showMessage("Select a project from the list", 3000)
|
||
|
||
def on_view_stats(self):
|
||
"""Handle view stats button."""
|
||
if self.current_project:
|
||
self.show_project_stats(self.current_project)
|
||
|
||
def show_project_stats(self, project: Project):
|
||
"""Show project statistics dialog."""
|
||
dialog = ProjectStatsDialog(project, self)
|
||
dialog.exec()
|
||
|
||
# ========================================================================
|
||
# Session Control
|
||
# ========================================================================
|
||
|
||
def start_session(self, project_id: int):
|
||
"""
|
||
Start a new session with the given project.
|
||
|
||
Args:
|
||
project_id: The ID of the project to start session for
|
||
"""
|
||
from core.project_manager import ProjectData
|
||
|
||
# Get real project from database
|
||
projects = self.project_manager.list_projects()
|
||
project = None
|
||
for p in projects:
|
||
if p.id == project_id:
|
||
project = p
|
||
break
|
||
|
||
if not project:
|
||
self.log_error("Session", f"Project {project_id} not found")
|
||
return
|
||
|
||
if self.session_state != SessionState.IDLE:
|
||
self.log_warning("Session", "Cannot start: session already active")
|
||
return
|
||
|
||
# Update state
|
||
self.set_session_state(SessionState.RUNNING)
|
||
self.current_session_id = project_id
|
||
|
||
# Emit signal
|
||
self.session_started.emit(project_id)
|
||
|
||
# Log
|
||
self.log_info("Session", f"Started session for project: {project.name}")
|
||
self.session_info_label.setText(f"Session active: {project.name}")
|
||
|
||
# Start real session in database
|
||
session = self.project_manager.start_session(project_id)
|
||
self._current_db_session_id = session.id if session else None
|
||
|
||
# Setup LogWatcher
|
||
use_mock = os.getenv('USE_MOCK_DATA', 'false').lower() in ('true', '1', 'yes')
|
||
log_path = os.getenv('EU_CHAT_LOG_PATH', '')
|
||
|
||
if use_mock or not log_path:
|
||
# Use mock log for testing
|
||
test_data_dir = Path(__file__).parent.parent / "test-data"
|
||
test_data_dir.mkdir(exist_ok=True)
|
||
mock_log = test_data_dir / "mock-chat.log"
|
||
if not mock_log.exists():
|
||
from core.log_watcher import MockLogGenerator
|
||
MockLogGenerator.create_mock_file(mock_log, lines=50)
|
||
self.log_watcher = LogWatcher(str(mock_log), poll_interval=2.0, mock_mode=True)
|
||
self.log_info("LogWatcher", "Using MOCK data for testing")
|
||
else:
|
||
self.log_watcher = LogWatcher(log_path, poll_interval=1.0, mock_mode=False)
|
||
self.log_info("LogWatcher", f"Using REAL log: {log_path}")
|
||
|
||
# Subscribe to events
|
||
self._setup_log_watcher_callbacks()
|
||
|
||
# Start LogWatcher in background
|
||
self._start_log_watcher()
|
||
|
||
# Show HUD and start session tracking
|
||
self.hud.show()
|
||
|
||
# Get gear names and costs from simplified loadout structure
|
||
session_display = getattr(self, '_session_display', {})
|
||
session_costs = getattr(self, '_session_costs', {})
|
||
|
||
weapon_name = session_display.get('weapon_name', self._selected_weapon or "Unknown")
|
||
armor_name = session_display.get('armor_name', "None")
|
||
healing_name = session_display.get('healing_name', "None")
|
||
loadout_name = "Loadout" if session_costs else "Default"
|
||
|
||
weapon_stats = self._selected_weapon_stats or {}
|
||
weapon_dpp = Decimal(str(weapon_stats.get('dpp', 0)))
|
||
weapon_cost_per_hour = Decimal(str(weapon_stats.get('cost_per_hour', 0)))
|
||
|
||
# Get cost data from simplified structure
|
||
cost_per_shot = session_costs.get('cost_per_shot', Decimal('0'))
|
||
cost_per_hit = session_costs.get('cost_per_hit', Decimal('0'))
|
||
cost_per_heal = session_costs.get('cost_per_heal', Decimal('0'))
|
||
|
||
self.hud.start_session(
|
||
weapon=weapon_name,
|
||
armor=armor_name,
|
||
fap=healing_name,
|
||
loadout=loadout_name,
|
||
weapon_dpp=weapon_dpp,
|
||
weapon_cost_per_hour=weapon_cost_per_hour,
|
||
cost_per_shot=cost_per_shot,
|
||
cost_per_hit=cost_per_hit,
|
||
cost_per_heal=cost_per_heal
|
||
)
|
||
|
||
# Simple cost tracking - no database required
|
||
self.log_info("CostTracker", "Cost tracking enabled with pre-calculated values")
|
||
|
||
self.log_info("HUD", f"HUD shown - Weapon: {weapon_name}, Armor: {armor_name}, Healing: {healing_name}, Loadout: {loadout_name}")
|
||
|
||
def _setup_log_watcher_callbacks(self):
|
||
"""Setup LogWatcher event callbacks."""
|
||
if not self.log_watcher:
|
||
return
|
||
|
||
from core.project_manager import LootEvent
|
||
from decimal import Decimal
|
||
|
||
def on_heal(event):
|
||
"""Handle heal events from chat.log.
|
||
|
||
Pattern: "You healed yourself X points"
|
||
Calculates healing cost based on FAP decay and updates HUD.
|
||
"""
|
||
heal_amount = event.data.get('heal_amount', Decimal('0'))
|
||
logger.debug(f"[EVENT] on_heal: heal_amount={heal_amount}")
|
||
|
||
# Calculate heal cost based on selected medical tool decay
|
||
# Get decay per heal from loadout or use default
|
||
decay_cost = Decimal('0')
|
||
if self._selected_loadout and hasattr(self._selected_loadout, 'heal_cost_pec'):
|
||
# heal_cost_pec is the decay per heal in PEC
|
||
# Convert to PED for cost calculation
|
||
decay_cost = self._selected_loadout.heal_cost_pec / Decimal('100')
|
||
elif self._selected_medical_tool_stats and 'decay' in self._selected_medical_tool_stats:
|
||
decay_pec = Decimal(str(self._selected_medical_tool_stats['decay']))
|
||
decay_cost = decay_pec / Decimal('100')
|
||
else:
|
||
# Default estimate: 2 PEC per heal
|
||
decay_cost = Decimal('0.02')
|
||
|
||
# Update HUD with heal event
|
||
try:
|
||
logger.debug(f"[DEBUG] _session_costs={self._session_costs}")
|
||
# Track healing cost
|
||
if self._session_costs:
|
||
cost_per_heal = self._session_costs.get('cost_per_heal', Decimal('0'))
|
||
logger.debug(f"[DEBUG] cost_per_heal={cost_per_heal}")
|
||
if cost_per_heal > 0:
|
||
logger.debug(f"[HUD] update_healing_cost({cost_per_heal})")
|
||
self.hud.update_healing_cost(cost_per_heal)
|
||
|
||
# Track heal amount (as event dict for new HUD)
|
||
logger.debug(f"[HUD] on_heal_event({{'heal_amount': {heal_amount}}})")
|
||
self.hud.on_heal_event({'heal_amount': heal_amount})
|
||
logger.debug(f"[HUD] Heal update successful")
|
||
except Exception as e:
|
||
logger.error(f"[ERROR] Error updating HUD heal: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
# Log to UI
|
||
self.log_info("Heal", f"Healed {heal_amount} HP (Cost: {decay_cost:.4f} PED)")
|
||
|
||
def on_loot(event):
|
||
"""Handle loot events."""
|
||
from decimal import Decimal
|
||
|
||
item_name = event.data.get('item_name', 'Unknown')
|
||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||
quantity = event.data.get('quantity', 1)
|
||
|
||
logger.debug(f"[EVENT] on_loot: item={item_name}, value={value_ped}, qty={quantity}")
|
||
|
||
# Skip Universal Ammo
|
||
if item_name == 'Universal Ammo':
|
||
logger.debug("[EVENT] on_loot: skipped Universal Ammo")
|
||
return
|
||
|
||
try:
|
||
# Update loot value - this also handles kill counting internally
|
||
is_shrapnel = 'shrapnel' in item_name.lower()
|
||
logger.debug(f"[HUD] update_loot({value_ped}, is_shrapnel={is_shrapnel})")
|
||
self.hud.update_loot(value_ped, is_shrapnel=is_shrapnel)
|
||
logger.debug(f"[HUD] Loot update successful")
|
||
except Exception as e:
|
||
logger.error(f"[ERROR] Error updating HUD loot: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
# Queue database write for main thread (SQLite thread safety)
|
||
if self._current_db_session_id:
|
||
self._event_queue.put({
|
||
'type': 'loot',
|
||
'session_id': self._current_db_session_id,
|
||
'item_name': item_name,
|
||
'quantity': quantity,
|
||
'value_ped': value_ped,
|
||
'raw_line': event.raw_line
|
||
})
|
||
|
||
# Log to UI (main thread only - use signal/slot or queue)
|
||
# We'll log this in _process_queued_events instead
|
||
|
||
def on_global(event):
|
||
"""Handle global events (other players)."""
|
||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||
player = event.data.get('player_name', 'Unknown')
|
||
# Don't count other players' globals in HUD
|
||
self.log_info("Global", f"{player} found {value_ped} PED!")
|
||
|
||
def on_personal_global(event):
|
||
"""Handle personal global events (only your globals)."""
|
||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||
creature = event.data.get('creature', 'Unknown')
|
||
player = event.data.get('player_name', 'Unknown')
|
||
|
||
# Only count if it matches our configured player name
|
||
if self.player_name and player.lower() == self.player_name.lower():
|
||
# Only count personal globals in HUD stats
|
||
self.hud.on_personal_global(value_ped)
|
||
self.log_info("Global", f"🎉 YOUR GLOBAL: {creature} for {value_ped} PED!!!")
|
||
else:
|
||
# Log but don't count - might be another player if names are similar
|
||
self.log_info("Global", f"{player} got global: {creature} for {value_ped} PED")
|
||
|
||
# If no player name set, warn user
|
||
if not self.player_name:
|
||
self.log_info("Config", "⚠️ Set your avatar name in Settings to track your globals correctly")
|
||
|
||
def on_hof(event):
|
||
"""Handle HoF events."""
|
||
value_ped = event.data.get('value_ped', Decimal('0.0'))
|
||
self.hud.on_hof(value_ped)
|
||
self.log_info("HoF", f"🏆 HALL OF FAME: {value_ped} PED!")
|
||
|
||
def on_skill(event):
|
||
"""Handle skill events."""
|
||
from decimal import Decimal
|
||
skill_name = event.data.get('skill_name', 'Unknown')
|
||
gained = event.data.get('gained', 0)
|
||
self.log_info("Skill", f"{skill_name} +{gained}")
|
||
|
||
# Update HUD skill tracking
|
||
try:
|
||
gained_decimal = Decimal(str(gained))
|
||
self.hud.update_skill(skill_name, gained_decimal)
|
||
except Exception as e:
|
||
logger.error(f"[ERROR] Error updating HUD skill: {e}")
|
||
|
||
def on_damage_dealt(event):
|
||
"""Handle damage dealt - track damage stats and weapon cost."""
|
||
from decimal import Decimal
|
||
try:
|
||
damage = event.data.get('damage', 0)
|
||
logger.debug(f"[EVENT] on_damage_dealt: damage={damage}")
|
||
if damage:
|
||
# Track damage amount
|
||
logger.debug(f"[HUD] on_damage_dealt({damage})")
|
||
self.hud.on_damage_dealt(Decimal(str(damage)))
|
||
|
||
# Track weapon cost per shot
|
||
logger.debug(f"[DEBUG] _session_costs={self._session_costs}")
|
||
if self._session_costs:
|
||
cost_per_shot = self._session_costs.get('cost_per_shot', Decimal('0'))
|
||
logger.debug(f"[DEBUG] cost_per_shot={cost_per_shot}")
|
||
if cost_per_shot > 0:
|
||
logger.debug(f"[HUD] update_weapon_cost({cost_per_shot})")
|
||
self.hud.update_weapon_cost(cost_per_shot)
|
||
except Exception as e:
|
||
logger.error(f"[ERROR] Error in on_damage_dealt: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
def on_critical_hit(event):
|
||
"""Handle critical hit - same as damage dealt."""
|
||
try:
|
||
on_damage_dealt(event)
|
||
except Exception as e:
|
||
logger.error(f"Error in on_critical_hit: {e}")
|
||
|
||
def on_damage_taken(event):
|
||
"""Handle damage taken - track damage stats and armor cost."""
|
||
from decimal import Decimal
|
||
try:
|
||
damage = event.data.get('damage', 0)
|
||
logger.debug(f"[EVENT] on_damage_taken: damage={damage}")
|
||
if damage:
|
||
# Track damage amount
|
||
logger.debug(f"[HUD] on_damage_taken({damage})")
|
||
self.hud.on_damage_taken(Decimal(str(damage)))
|
||
|
||
# Calculate armor decay based on actual damage absorbed
|
||
# Formula: Decay (PEC) = damage_absorbed * 0.05 * (1 - durability/100000)
|
||
# Approximate: ~0.05 PEC per damage point for non-L armor
|
||
# For simplicity: 0.0005 PED per damage point (0.05 PEC = 0.0005 PED)
|
||
if self._selected_armor_stats and 'decay' in self._selected_armor_stats:
|
||
# Use decay per hit from armor stats if available
|
||
armor_decay_pec = Decimal(str(self._selected_armor_stats.get('decay', 0)))
|
||
# Approximate: decay scales with damage
|
||
# Most armor has ~0.05 PEC decay per damage point
|
||
decay_per_damage = Decimal('0.0005') # 0.05 PEC = 0.0005 PED
|
||
cost_ped = Decimal(str(damage)) * decay_per_damage
|
||
if cost_ped > 0:
|
||
logger.debug(f"[HUD] update_armor_cost({cost_ped:.4f}) based on damage {damage}")
|
||
self.hud.update_armor_cost(cost_ped)
|
||
else:
|
||
# Fallback to simple calculation
|
||
cost_per_hit = self._session_costs.get('cost_per_hit', Decimal('0'))
|
||
if cost_per_hit > 0:
|
||
logger.debug(f"[HUD] update_armor_cost({cost_per_hit}) [fallback]")
|
||
self.hud.update_armor_cost(cost_per_hit)
|
||
except Exception as e:
|
||
logger.error(f"[ERROR] Error in on_damage_taken: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
def on_evade(event):
|
||
"""Handle evade."""
|
||
evade_type = event.data.get('type', 'Evade')
|
||
self.log_info("Evade", evade_type)
|
||
|
||
# Subscribe to all event types
|
||
self.log_watcher.subscribe('loot', on_loot)
|
||
self.log_watcher.subscribe('global', on_global)
|
||
self.log_watcher.subscribe('personal_global', on_personal_global)
|
||
self.log_watcher.subscribe('hof', on_hof)
|
||
self.log_watcher.subscribe('skill', on_skill)
|
||
self.log_watcher.subscribe('damage_dealt', on_damage_dealt)
|
||
self.log_watcher.subscribe('critical_hit', on_critical_hit)
|
||
self.log_watcher.subscribe('damage_taken', on_damage_taken)
|
||
self.log_watcher.subscribe('evade', on_evade)
|
||
self.log_watcher.subscribe('heal', on_heal) # NEW: Heal event tracking
|
||
|
||
def _start_log_watcher(self):
|
||
"""Start LogWatcher in background thread."""
|
||
import asyncio
|
||
from PyQt6.QtCore import QThread
|
||
|
||
class LogWatcherThread(QThread):
|
||
def __init__(self, watcher):
|
||
super().__init__()
|
||
self.watcher = watcher
|
||
self._running = True
|
||
|
||
def run(self):
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
loop.run_until_complete(self.watcher.start())
|
||
while self._running:
|
||
loop.run_until_complete(asyncio.sleep(0.1))
|
||
except Exception as e:
|
||
print(f"LogWatcher error: {e}")
|
||
finally:
|
||
loop.run_until_complete(self.watcher.stop())
|
||
loop.close()
|
||
|
||
def stop(self):
|
||
self._running = False
|
||
|
||
self._log_watcher_thread = LogWatcherThread(self.log_watcher)
|
||
self._log_watcher_thread.start()
|
||
self.log_info("LogWatcher", "Started watching for events")
|
||
|
||
def _stop_log_watcher(self):
|
||
"""Stop LogWatcher."""
|
||
if hasattr(self, '_log_watcher_thread') and self._log_watcher_thread:
|
||
self._log_watcher_thread.stop()
|
||
self._log_watcher_thread.wait(2000) # Wait up to 2 seconds
|
||
self._log_watcher_thread = None
|
||
self.log_info("LogWatcher", "Stopped")
|
||
|
||
def _setup_session_cost_tracker(self, loadout_info: dict):
|
||
"""Set up session cost tracker with selected loadout."""
|
||
from core.session_cost_tracker import SessionCostTracker
|
||
from core.database import DatabaseManager
|
||
|
||
loadout_id = loadout_info.get('id')
|
||
loadout_source = loadout_info.get('source')
|
||
|
||
# If JSON-based loadout, we need to save it to database first or handle differently
|
||
if loadout_source == 'json':
|
||
# For now, skip cost tracking for JSON loadouts
|
||
# TODO: Save JSON loadout to database first, then use its ID
|
||
self.log_warning("CostTracker", "Cost tracking not available for file-based loadouts. Save to database first.")
|
||
return
|
||
|
||
try:
|
||
db = DatabaseManager()
|
||
self._cost_tracker = SessionCostTracker(
|
||
session_id=self._current_db_session_id,
|
||
loadout_id=loadout_id,
|
||
db_manager=db
|
||
)
|
||
self._cost_tracker.register_callback(self._on_cost_update)
|
||
self.log_info("CostTracker", f"Cost tracking enabled for loadout ID: {loadout_id}")
|
||
except Exception as e:
|
||
self.log_error("CostTracker", f"Failed to set up cost tracker: {e}")
|
||
|
||
def _on_cost_update(self, state):
|
||
"""Handle cost update from SessionCostTracker."""
|
||
# Update HUD with new cost state
|
||
if hasattr(self, 'hud') and self.hud:
|
||
summary = {
|
||
'weapon_cost': state.weapon_cost,
|
||
'armor_cost': state.armor_cost,
|
||
'healing_cost': state.healing_cost,
|
||
'enhancer_cost': state.enhancer_cost,
|
||
'mindforce_cost': state.mindforce_cost,
|
||
'shots_fired': state.shots_fired,
|
||
'hits_taken': state.hits_taken,
|
||
'heals_used': state.heals_used,
|
||
}
|
||
self.hud._stats.update_from_cost_tracker(summary)
|
||
self.hud._refresh_display()
|
||
|
||
def _process_queued_events(self):
|
||
"""Process events from the queue in the main thread (SQLite thread safety)."""
|
||
from core.project_manager import LootEvent
|
||
from decimal import Decimal
|
||
|
||
processed = 0
|
||
while not self._event_queue.empty() and processed < 10: # Process max 10 per tick
|
||
try:
|
||
event = self._event_queue.get_nowait()
|
||
|
||
if event['type'] == 'loot':
|
||
# Record to database (now in main thread - safe)
|
||
loot = LootEvent(
|
||
item_name=event['item_name'],
|
||
quantity=event['quantity'],
|
||
value_ped=event['value_ped'],
|
||
event_type='regular',
|
||
raw_log_line=event['raw_line']
|
||
)
|
||
self.project_manager.record_loot(loot)
|
||
|
||
# Log to UI
|
||
self.log_info("Loot", f"{event['item_name']} x{event['quantity']} ({event['value_ped']} PED)")
|
||
|
||
processed += 1
|
||
except Exception as e:
|
||
self.log_error("EventQueue", f"Error processing event: {e}")
|
||
|
||
def on_start_session(self):
|
||
"""Handle start session button - shows loadout selection first."""
|
||
if self.current_project and self.session_state == SessionState.IDLE:
|
||
# Show loadout selection dialog
|
||
from ui.loadout_selection_dialog_simple import LoadoutSelectionDialog
|
||
dialog = LoadoutSelectionDialog(self)
|
||
dialog.loadout_selected.connect(self._on_loadout_selected_for_session)
|
||
dialog.rejected.connect(lambda: self.log_info("Session", "Session start cancelled - no loadout selected"))
|
||
dialog.exec()
|
||
|
||
def _on_loadout_selected_for_session(self, loadout_info: dict):
|
||
"""Handle loadout selection and start session - simplified cost-focused version."""
|
||
loadout_name = loadout_info.get('name', 'No Loadout')
|
||
costs = loadout_info.get('costs', {})
|
||
display = loadout_info.get('display', {})
|
||
|
||
# Store cost data for session tracking
|
||
from decimal import Decimal
|
||
self._session_costs = {
|
||
'cost_per_shot': costs.get('cost_per_shot', Decimal('0')),
|
||
'cost_per_hit': costs.get('cost_per_hit', Decimal('0')),
|
||
'cost_per_heal': costs.get('cost_per_heal', Decimal('0')),
|
||
}
|
||
|
||
# Store display data for HUD
|
||
self._session_display = {
|
||
'weapon_name': display.get('weapon_name', 'None'),
|
||
'armor_name': display.get('armor_name', 'None'),
|
||
'healing_name': display.get('healing_name', 'None'),
|
||
}
|
||
|
||
if any(self._session_costs.values()):
|
||
self.log_info("Session", f"Starting with loadout: {loadout_name}")
|
||
self.log_info("SessionCosts",
|
||
f"Shot: {self._session_costs['cost_per_shot']:.4f} PED, "
|
||
f"Hit: {self._session_costs['cost_per_hit']:.4f} PED, "
|
||
f"Heal: {self._session_costs['cost_per_heal']:.4f} PED")
|
||
else:
|
||
self.log_info("Session", f"Starting with loadout: {loadout_name} (no costs configured)")
|
||
|
||
# Now start the session
|
||
if self.current_project:
|
||
self.start_session(self.current_project.id)
|
||
|
||
def on_stop_session(self):
|
||
"""Handle stop session button."""
|
||
if self.session_state in (SessionState.RUNNING, SessionState.PAUSED):
|
||
# Stop LogWatcher
|
||
self._stop_log_watcher()
|
||
|
||
# Save HUD costs to database before ending session
|
||
if self._current_db_session_id and self.hud:
|
||
from decimal import Decimal
|
||
from datetime import datetime
|
||
|
||
# Save weapon decay
|
||
if self.hud._stats.weapon_cost_total > 0:
|
||
self.db.execute(
|
||
"""INSERT INTO decay_events
|
||
(session_id, item_name, decay_amount_ped, timestamp)
|
||
VALUES (?, ?, ?, ?)""",
|
||
(self._current_db_session_id, 'Weapon',
|
||
float(self.hud._stats.weapon_cost_total), datetime.now())
|
||
)
|
||
|
||
# Save armor decay
|
||
if self.hud._stats.armor_cost_total > 0:
|
||
self.db.execute(
|
||
"""INSERT INTO decay_events
|
||
(session_id, item_name, decay_amount_ped, timestamp)
|
||
VALUES (?, ?, ?, ?)""",
|
||
(self._current_db_session_id, 'Armor',
|
||
float(self.hud._stats.armor_cost_total), datetime.now())
|
||
)
|
||
|
||
# Save healing decay
|
||
if self.hud._stats.healing_cost_total > 0:
|
||
self.db.execute(
|
||
"""INSERT INTO decay_events
|
||
(session_id, item_name, decay_amount_ped, timestamp)
|
||
VALUES (?, ?, ?, ?)""",
|
||
(self._current_db_session_id, 'Healing',
|
||
float(self.hud._stats.healing_cost_total), datetime.now())
|
||
)
|
||
|
||
self.db.commit()
|
||
self.log_info("Session",
|
||
f"Saved costs to DB: Weapon={self.hud._stats.weapon_cost_total:.2f}, "
|
||
f"Armor={self.hud._stats.armor_cost_total:.2f}, "
|
||
f"Healing={self.hud._stats.healing_cost_total:.2f}")
|
||
|
||
# End session in database
|
||
if self._current_db_session_id:
|
||
self.project_manager.end_session(self._current_db_session_id)
|
||
self._current_db_session_id = None
|
||
|
||
self.set_session_state(SessionState.IDLE)
|
||
self.current_session_id = None
|
||
|
||
self.session_stopped.emit()
|
||
|
||
self.log_info("Session", "Session stopped")
|
||
self.session_info_label.setText("Session stopped")
|
||
|
||
# End HUD session
|
||
self.hud.end_session()
|
||
|
||
# Hide HUD
|
||
self.hud.hide()
|
||
|
||
def on_pause_session(self):
|
||
"""Handle pause/resume session button."""
|
||
if self.session_state == SessionState.RUNNING:
|
||
self.set_session_state(SessionState.PAUSED)
|
||
self.session_paused.emit()
|
||
# Pause HUD tracking
|
||
if self.hud:
|
||
self.hud.session_active = False
|
||
self.hud.status_label.setText("● Paused")
|
||
self.hud.status_label.setStyleSheet("color: #FF9800; font-weight: bold;")
|
||
self.log_info("Session", "Session paused - tracking stopped")
|
||
self.session_info_label.setText("Session paused")
|
||
self.pause_session_btn.setText("▶️ Resume")
|
||
elif self.session_state == SessionState.PAUSED:
|
||
self.set_session_state(SessionState.RUNNING)
|
||
self.session_resumed.emit()
|
||
# Resume HUD tracking
|
||
if self.hud:
|
||
self.hud.session_active = True
|
||
self.hud.status_label.setText("● Live")
|
||
self.hud.status_label.setStyleSheet("color: #7FFF7F; font-weight: bold;")
|
||
self.log_info("Session", "Session resumed - tracking started")
|
||
self.session_info_label.setText("Session resumed")
|
||
self.pause_session_btn.setText("⏸️ Pause")
|
||
|
||
def set_session_state(self, state: SessionState):
|
||
"""
|
||
Update the session state and UI.
|
||
|
||
Args:
|
||
state: New session state
|
||
"""
|
||
self.session_state = state
|
||
|
||
# Update status label
|
||
colors = {
|
||
SessionState.IDLE: "#888",
|
||
SessionState.RUNNING: "#4caf50",
|
||
SessionState.PAUSED: "#ff9800",
|
||
SessionState.ERROR: "#f44336",
|
||
SessionState.STOPPING: "#ff5722"
|
||
}
|
||
|
||
self.session_status_label.setText(state.value)
|
||
self.session_status_label.setStyleSheet(f"""
|
||
QLabel {{
|
||
font-weight: bold;
|
||
color: {colors.get(state, '#888')};
|
||
padding: 5px 15px;
|
||
background-color: #2a2a2a;
|
||
border-radius: 4px;
|
||
border: 1px solid #444;
|
||
}}
|
||
""")
|
||
|
||
# Update status bar
|
||
self.status_state_label.setText(f"● {state.value}")
|
||
self.status_state_label.setStyleSheet(f"color: {colors.get(state, '#888')}; padding: 0 10px;")
|
||
|
||
# Update buttons
|
||
self.start_session_btn.setEnabled(
|
||
state == SessionState.IDLE and self.current_project is not None
|
||
)
|
||
self.stop_session_btn.setEnabled(state in (SessionState.RUNNING, SessionState.PAUSED))
|
||
self.pause_session_btn.setEnabled(state in (SessionState.RUNNING, SessionState.PAUSED))
|
||
|
||
# Update menu actions
|
||
self.start_action.setEnabled(self.start_session_btn.isEnabled())
|
||
self.stop_action.setEnabled(self.stop_session_btn.isEnabled())
|
||
self.pause_action.setEnabled(self.pause_session_btn.isEnabled())
|
||
|
||
if state == SessionState.IDLE:
|
||
self.pause_session_btn.setText("⏸️ Pause")
|
||
|
||
# ========================================================================
|
||
# Log Handling
|
||
# ========================================================================
|
||
|
||
def on_log_event(self, event: LogEvent):
|
||
"""
|
||
Handle incoming log events.
|
||
|
||
Args:
|
||
event: The log event to display
|
||
"""
|
||
# Color mapping
|
||
colors = {
|
||
"DEBUG": "#888",
|
||
"INFO": "#4fc3f7",
|
||
"WARNING": "#ff9800",
|
||
"ERROR": "#f44336",
|
||
"CRITICAL": "#e91e63"
|
||
}
|
||
|
||
color = colors.get(event.level, "#e0e0e0")
|
||
html = f'<span style="color: {color}">{self.escape_html(str(event))}</span>'
|
||
|
||
self.log_output.append(html)
|
||
|
||
# Auto-scroll to bottom
|
||
scrollbar = self.log_output.verticalScrollBar()
|
||
scrollbar.setValue(scrollbar.maximum())
|
||
|
||
def log_debug(self, source: str, message: str):
|
||
"""Log a debug message."""
|
||
self._append_log("DEBUG", source, message)
|
||
|
||
def log_info(self, source: str, message: str):
|
||
"""Log an info message."""
|
||
self._append_log("INFO", source, message)
|
||
|
||
def log_warning(self, source: str, message: str):
|
||
"""Log a warning message."""
|
||
self._append_log("WARNING", source, message)
|
||
|
||
def log_error(self, source: str, message: str):
|
||
"""Log an error message."""
|
||
self._append_log("ERROR", source, message)
|
||
|
||
def _append_log(self, level: str, source: str, message: str):
|
||
"""Append log message to output."""
|
||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||
color = {
|
||
"DEBUG": "#888888",
|
||
"INFO": "#4caf50",
|
||
"WARNING": "#ff9800",
|
||
"ERROR": "#f44336"
|
||
}.get(level, "#ffffff")
|
||
|
||
log_entry = f'<span style="color: #666;">[{timestamp}]</span> <span style="color: {color};">[{level}]</span> <span style="color: #aaa;">[{source}]</span> {self.escape_html(message)}'
|
||
self.log_output.append(log_entry)
|
||
|
||
# Auto-scroll to bottom
|
||
scrollbar = self.log_output.verticalScrollBar()
|
||
scrollbar.setValue(scrollbar.maximum())
|
||
|
||
def escape_html(self, text: str) -> str:
|
||
"""Escape HTML special characters."""
|
||
return (text
|
||
.replace("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">"))
|
||
|
||
# ========================================================================
|
||
# Menu Actions
|
||
# ========================================================================
|
||
|
||
def on_show_hud(self):
|
||
"""Show the HUD overlay."""
|
||
self.hud.show()
|
||
self.log_info("HUD", "HUD overlay shown")
|
||
|
||
def on_hide_hud(self):
|
||
"""Hide the HUD overlay."""
|
||
self.hud.hide()
|
||
self.log_info("HUD", "HUD overlay hidden")
|
||
|
||
def on_settings(self):
|
||
"""Open settings dialog."""
|
||
dialog = SettingsDialog(self, self.player_name)
|
||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||
self.player_name = dialog.get_player_name()
|
||
self._save_settings() # Save to persistent storage
|
||
if self.player_name:
|
||
self.log_info("Settings", f"Avatar name set to: {self.player_name}")
|
||
else:
|
||
self.log_info("Settings", "Warning: No avatar name set. Global tracking may not work correctly.")
|
||
|
||
def _load_settings(self):
|
||
"""Load persistent settings from QSettings."""
|
||
settings = QSettings("Lemontropia", "Suite")
|
||
self.player_name = settings.value("player/name", "", type=str)
|
||
if self.player_name:
|
||
self.log_info("Settings", f"Loaded avatar name: {self.player_name}")
|
||
|
||
def _save_settings(self):
|
||
"""Save persistent settings to QSettings."""
|
||
settings = QSettings("Lemontropia", "Suite")
|
||
settings.setValue("player/name", self.player_name)
|
||
settings.sync() # Ensure settings are written immediately
|
||
|
||
def on_loadout_manager(self):
|
||
"""Open Loadout Manager dialog."""
|
||
from ui.loadout_manager_simple import LoadoutManagerDialog
|
||
dialog = LoadoutManagerDialog(self)
|
||
dialog.loadout_saved.connect(self.on_loadout_selected)
|
||
dialog.exec()
|
||
|
||
def on_loadout_selected(self, loadout):
|
||
"""Handle loadout selection from Loadout Manager - simplified version."""
|
||
self._selected_loadout = loadout
|
||
self.log_info("Loadout", f"Selected loadout: {loadout.name}")
|
||
|
||
# Update selected gear from loadout (simplified structure)
|
||
if hasattr(loadout, 'weapon_name'):
|
||
self._selected_weapon = loadout.weapon_name
|
||
if hasattr(loadout, 'healing_name'):
|
||
self._selected_medical_tool = loadout.healing_name
|
||
self._selected_medical_tool_stats = {
|
||
'decay': float(loadout.healing_decay_pec),
|
||
'cost_per_heal': float(loadout.healing_cost_per_heal),
|
||
}
|
||
# Store simplified costs for session
|
||
if hasattr(loadout, 'weapon_cost_per_shot'):
|
||
self._session_costs = {
|
||
'cost_per_shot': loadout.weapon_cost_per_shot,
|
||
'cost_per_hit': loadout.armor_cost_per_hit,
|
||
'cost_per_heal': loadout.healing_cost_per_heal,
|
||
}
|
||
if hasattr(loadout, 'weapon_name'):
|
||
self._session_display = {
|
||
'weapon_name': loadout.weapon_name,
|
||
'armor_name': loadout.armor_name,
|
||
'healing_name': loadout.healing_name,
|
||
}
|
||
|
||
def on_select_gear(self, gear_type: str = "weapon"):
|
||
"""Open Gear Selector dialog."""
|
||
from ui.gear_selector import GearSelectorDialog
|
||
|
||
dialog = GearSelectorDialog(gear_type, self)
|
||
dialog.gear_selected.connect(self.on_gear_selected)
|
||
dialog.exec()
|
||
|
||
def on_gear_selected(self, gear_type: str, name: str, stats: dict):
|
||
"""Handle gear selection."""
|
||
self.log_info("Gear", f"Selected {gear_type}: {name}")
|
||
|
||
if gear_type == "weapon":
|
||
self._selected_weapon = name
|
||
self._selected_weapon_stats = stats
|
||
if self.session_state == SessionState.RUNNING:
|
||
self.hud.update_stats({'weapon': name})
|
||
elif gear_type == "armor":
|
||
self._selected_armor = name
|
||
self._selected_armor_stats = stats
|
||
elif gear_type == "finder":
|
||
self._selected_finder = name
|
||
self._selected_finder_stats = stats
|
||
elif gear_type == "medical_tool":
|
||
self._selected_medical_tool = name
|
||
self._selected_medical_tool_stats = stats
|
||
if self.session_state == SessionState.RUNNING:
|
||
self.hud.update_stats({'medical_tool': name})
|
||
|
||
def on_about(self):
|
||
"""Show about dialog."""
|
||
QMessageBox.about(
|
||
self,
|
||
"About Lemontropia Suite",
|
||
"""<h2>Lemontropia Suite</h2>
|
||
<p>Version 1.0.0</p>
|
||
<p>A PyQt6-based GUI for game automation and session management.</p>
|
||
<p>Features:</p>
|
||
<ul>
|
||
<li>Project management</li>
|
||
<li>Session control</li>
|
||
<li>Real-time logging</li>
|
||
<li>HUD overlay</li>
|
||
</ul>
|
||
"""
|
||
)
|
||
|
||
# ========================================================================
|
||
# Event Overrides
|
||
# ========================================================================
|
||
|
||
def closeEvent(self, event):
|
||
"""Handle window close event."""
|
||
if self.session_state == SessionState.RUNNING:
|
||
reply = QMessageBox.question(
|
||
self,
|
||
"Confirm Exit",
|
||
"A session is currently running. Are you sure you want to exit?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No
|
||
)
|
||
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
self.on_stop_session()
|
||
event.accept()
|
||
else:
|
||
event.ignore()
|
||
else:
|
||
event.accept()
|
||
|
||
|
||
# ============================================================================
|
||
# Test Entry Point
|
||
# ============================================================================
|
||
|
||
def main():
|
||
"""Main entry point for testing."""
|
||
app = QApplication(sys.argv)
|
||
|
||
# Set application-wide font
|
||
font = QFont("Segoe UI", 10)
|
||
app.setFont(font)
|
||
|
||
# Create and show main window
|
||
window = MainWindow()
|
||
window.show()
|
||
|
||
# Simulate some log activity for demonstration
|
||
def simulate_logs():
|
||
import random
|
||
sources = ["Engine", "Input", "Vision", "Network", "Session"]
|
||
levels = ["DEBUG", "INFO", "INFO", "INFO", "WARNING"]
|
||
messages = [
|
||
"Initializing component...",
|
||
"Connection established",
|
||
"Processing frame #1234",
|
||
"Waiting for input",
|
||
"Buffer cleared",
|
||
"Sync complete"
|
||
]
|
||
|
||
if window.session_state == SessionState.RUNNING:
|
||
if random.random() < 0.3: # 30% chance each tick
|
||
event = LogEvent(
|
||
timestamp=datetime.now(),
|
||
level=random.choice(levels),
|
||
source=random.choice(sources),
|
||
message=random.choice(messages)
|
||
)
|
||
window.log_watcher.emit(event)
|
||
|
||
# Timer to simulate log activity
|
||
timer = QTimer()
|
||
timer.timeout.connect(simulate_logs)
|
||
timer.start(1000) # Every second
|
||
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|