# Description: ProjectManager implementing the Data Principle # Every session (Hunt/Mine/Craft) is a Project with auto-save, archive, reload # Standards: Python 3.11+, type hints, Decimal precision for PED/PEC from decimal import Decimal from datetime import datetime from typing import Optional, Dict, Any, List from dataclasses import dataclass, asdict import json import logging from .database import DatabaseManager logger = logging.getLogger(__name__) @dataclass class ProjectData: """Data class representing a Project (Data Principle core).""" id: Optional[int] = None name: str = "" type: str = "hunt" # hunt, mine, craft, inventory status: str = "active" # active, paused, completed, archived created_at: Optional[datetime] = None updated_at: Optional[datetime] = None archived_at: Optional[datetime] = None metadata: Dict[str, Any] = None def __post_init__(self): if self.metadata is None: self.metadata = {} @dataclass class SessionData: """Data class representing a gameplay Session.""" id: Optional[int] = None project_id: Optional[int] = None started_at: Optional[datetime] = None ended_at: Optional[datetime] = None duration_seconds: int = 0 total_spent_ped: Decimal = Decimal("0.0") total_return_ped: Decimal = Decimal("0.0") net_profit_ped: Decimal = Decimal("0.0") notes: str = "" @dataclass class LootEvent: """Data class representing a loot event from chat.log.""" id: Optional[int] = None session_id: Optional[int] = None timestamp: Optional[datetime] = None event_type: str = "regular" # global, hof, regular, skill item_name: Optional[str] = None quantity: int = 1 value_ped: Decimal = Decimal("0.0") creature_name: Optional[str] = None zone_name: Optional[str] = None raw_log_line: Optional[str] = None screenshot_path: Optional[str] = None class ProjectManager: """ Manages Projects implementing the Data Principle. The Data Principle states: Every session (Hunt/Mine/Craft) is a Project. - Projects are auto-saved - Projects can be archived and reloaded - Historical data is comparable to current live data Attributes: db: DatabaseManager instance for persistence current_project: Currently active ProjectData current_session: Currently active SessionData """ def __init__(self, db: Optional[DatabaseManager] = None): """ Initialize ProjectManager. Args: db: DatabaseManager instance. Creates default if None. """ self.db = db if db else DatabaseManager() self.current_project: Optional[ProjectData] = None self.current_session: Optional[SessionData] = None logger.info("ProjectManager initialized") # ======================================================================== # PROJECT OPERATIONS # ======================================================================== def create_project(self, name: str, project_type: str, metadata: Optional[Dict] = None) -> ProjectData: """ Create a new project. Args: name: Project name project_type: One of: hunt, mine, craft, inventory metadata: Optional project metadata dict Returns: Created ProjectData with assigned ID """ project = ProjectData( name=name, type=project_type, status="active", metadata=metadata or {} ) cursor = self.db.execute( """ INSERT INTO projects (name, type, status, metadata) VALUES (?, ?, ?, ?) """, (name, project_type, "active", json.dumps(metadata or {})) ) self.db.commit() project.id = cursor.lastrowid logger.info(f"Created project: {name} (ID: {project.id}, Type: {project_type})") return project def load_project(self, project_id: int) -> Optional[ProjectData]: """ Load a project by ID. Args: project_id: Project ID to load Returns: ProjectData if found, None otherwise """ cursor = self.db.execute( "SELECT * FROM projects WHERE id = ?", (project_id,) ) row = cursor.fetchone() if not row: logger.warning(f"Project not found: {project_id}") return None project = ProjectData( id=row['id'], name=row['name'], type=row['type'], status=row['status'], created_at=row['created_at'], updated_at=row['updated_at'], archived_at=row['archived_at'], metadata=json.loads(row['metadata']) if row['metadata'] else {} ) self.current_project = project logger.info(f"Loaded project: {project.name} (ID: {project.id})") return project def list_projects(self, project_type: Optional[str] = None, status: Optional[str] = None) -> List[ProjectData]: """ List all projects with optional filtering. Args: project_type: Filter by type (hunt, mine, craft, inventory) status: Filter by status (active, paused, completed, archived) Returns: List of ProjectData """ query = "SELECT * FROM projects WHERE 1=1" params = [] if project_type: query += " AND type = ?" params.append(project_type) if status: query += " AND status = ?" params.append(status) query += " ORDER BY created_at DESC" cursor = self.db.execute(query, tuple(params)) rows = cursor.fetchall() projects = [] for row in rows: projects.append(ProjectData( id=row['id'], name=row['name'], type=row['type'], status=row['status'], created_at=row['created_at'], updated_at=row['updated_at'], archived_at=row['archived_at'], metadata=json.loads(row['metadata']) if row['metadata'] else {} )) return projects def archive_project(self, project_id: int) -> bool: """ Archive a project. Args: project_id: Project ID to archive Returns: True if successful """ self.db.execute( """ UPDATE projects SET status = 'archived', archived_at = CURRENT_TIMESTAMP WHERE id = ? """, (project_id,) ) self.db.commit() logger.info(f"Archived project: {project_id}") return True # ======================================================================== # SESSION OPERATIONS # ======================================================================== def start_session(self, project_id: int, notes: str = "") -> SessionData: """ Start a new session for a project. Args: project_id: Parent project ID notes: Optional session notes Returns: Created SessionData """ cursor = self.db.execute( """ INSERT INTO sessions (project_id, notes) VALUES (?, ?) """, (project_id, notes) ) self.db.commit() session = SessionData( id=cursor.lastrowid, project_id=project_id, notes=notes ) self.current_session = session # Update project status self.db.execute( "UPDATE projects SET status = 'active' WHERE id = ?", (project_id,) ) self.db.commit() logger.info(f"Started session {session.id} for project {project_id}") return session def end_session(self, session_id: Optional[int] = None) -> bool: """ End a session and calculate final metrics. Args: session_id: Session ID (uses current_session if None) Returns: True if successful """ if session_id is None: session_id = self.current_session.id if self.current_session else None if not session_id: logger.error("No session ID provided") return False # Calculate metrics cursor = self.db.execute( """ SELECT COALESCE(SUM(value_ped), 0) as total_return, COALESCE(SUM(CASE WHEN event_type IN ('global', 'hof') THEN 1 ELSE 0 END), 0) as globals FROM loot_events WHERE session_id = ? """, (session_id,) ) result = cursor.fetchone() total_return = Decimal(str(result['total_return'])) if result else Decimal("0.0") # Get decay (spent) cursor = self.db.execute( "SELECT COALESCE(SUM(decay_amount_ped), 0) as total_decay FROM decay_events WHERE session_id = ?", (session_id,) ) result = cursor.fetchone() total_spent = Decimal(str(result['total_decay'])) if result else Decimal("0.0") net_profit = total_return - total_spent # End session self.db.execute( """ UPDATE sessions SET ended_at = CURRENT_TIMESTAMP, total_return_ped = ?, total_spent_ped = ?, net_profit_ped = ? WHERE id = ? """, (float(total_return), float(total_spent), float(net_profit), session_id) ) self.db.commit() if self.current_session and self.current_session.id == session_id: self.current_session = None logger.info(f"Ended session {session_id}. Profit: {net_profit} PED") return True # ======================================================================== # LOOT TRACKING # ======================================================================== def record_loot(self, loot: LootEvent) -> bool: """ Record a loot event. Args: loot: LootEvent data Returns: True if recorded successfully """ if not self.current_session: logger.error("No active session to record loot") return False loot.session_id = self.current_session.id cursor = self.db.execute( """ INSERT INTO loot_events (session_id, event_type, item_name, quantity, value_ped, creature_name, zone_name, raw_log_line, screenshot_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( loot.session_id, loot.event_type, loot.item_name, loot.quantity, float(loot.value_ped), loot.creature_name, loot.zone_name, loot.raw_log_line, loot.screenshot_path ) ) self.db.commit() logger.debug(f"Recorded loot: {loot.item_name} ({loot.value_ped} PED)") # Check for screenshot trigger (Rule: >50 PED) if loot.value_ped >= Decimal("50.0") and loot.event_type in ('global', 'hof'): self._trigger_screenshot(loot) return True def _trigger_screenshot(self, loot: LootEvent) -> None: """ Trigger screenshot capture for high-value loot. Args: loot: The loot event that triggered screenshot """ # Placeholder - actual implementation in screenshot module logger.info(f"Screenshot triggered for {loot.value_ped} PED {loot.event_type}") # ======================================================================== # ANALYTICS # ======================================================================== def get_project_summary(self, project_id: int) -> Optional[Dict[str, Any]]: """ Get summary statistics for a project. Args: project_id: Project ID Returns: Dictionary with summary statistics """ cursor = self.db.execute( "SELECT * FROM v_project_summary WHERE id = ?", (project_id,) ) row = cursor.fetchone() if not row: return None return { 'project_id': row['id'], 'name': row['name'], 'type': row['type'], 'status': row['status'], 'session_count': row['session_count'], 'total_spent': Decimal(str(row['total_spent'])) if row['total_spent'] else Decimal("0.0"), 'total_return': Decimal(str(row['total_return'])) if row['total_return'] else Decimal("0.0"), 'net_profit': Decimal(str(row['net_profit'])) if row['net_profit'] else Decimal("0.0"), 'global_count': row['global_count'], 'hof_count': row['hof_count'] } def compare_to_historical(self, project_id: int, metric: str = 'net_profit') -> Dict[str, Any]: """ Compare current project to historical averages. Implements Data Principle: Comparable historical data. Args: project_id: Current project ID metric: Metric to compare (net_profit, roi, globals_per_hour) Returns: Comparison data """ # Get current project type cursor = self.db.execute( "SELECT type FROM projects WHERE id = ?", (project_id,) ) row = cursor.fetchone() if not row: return {} project_type = row['type'] # Get historical averages for same type cursor = self.db.execute( """ SELECT AVG(net_profit_ped) as avg_profit, AVG(CASE WHEN total_spent_ped > 0 THEN (net_profit_ped / total_spent_ped) * 100 ELSE 0 END) as avg_roi FROM sessions s JOIN projects p ON s.project_id = p.id WHERE p.type = ? AND p.id != ? AND p.status = 'archived' """, (project_type, project_id) ) hist = cursor.fetchone() # Get current project stats current = self.get_project_summary(project_id) return { 'current': current, 'historical_avg_profit': Decimal(str(hist['avg_profit'])) if hist['avg_profit'] else Decimal("0.0"), 'historical_avg_roi': hist['avg_roi'] or 0, 'comparison_metric': metric } # ============================================================================ # MODULE EXPORTS # ============================================================================ __all__ = [ 'ProjectManager', 'ProjectData', 'SessionData', 'LootEvent' ]