diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..b60198d --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,19 @@ +# Description: Core module initialization +# Exports main classes for Lemontropia Suite engine + +from .database import DatabaseManager +from .project_manager import ProjectManager, ProjectData, SessionData, LootEvent +from .log_watcher import LogWatcher, LogEvent, MockLogGenerator + +__all__ = [ + 'DatabaseManager', + 'ProjectManager', + 'ProjectData', + 'SessionData', + 'LootEvent', + 'LogWatcher', + 'LogEvent', + 'MockLogGenerator', +] + +__version__ = '0.1.0-alpha' diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..8e33463 --- /dev/null +++ b/core/database.py @@ -0,0 +1,228 @@ +# Description: Database initialization and connection management +# Implements SQLite setup with Data Principle support +# Standards: Python 3.11+, type hints, async where possible + +import sqlite3 +import os +from pathlib import Path +from decimal import Decimal +from datetime import datetime +from typing import Optional, Any +import json +import logging + +# Configure logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +class DatabaseManager: + """ + Manages SQLite database connections and schema initialization. + + Implements the Data Principle by providing robust data persistence + with foreign key support, transactions, and connection pooling. + """ + + def __init__(self, db_path: Optional[str] = None): + """ + Initialize database manager. + + Args: + db_path: Path to SQLite database. Defaults to ./data/lemontropia.db + """ + if db_path is None: + # Get project root (parent of core/) + core_dir = Path(__file__).parent + project_root = core_dir.parent + db_path = project_root / "data" / "lemontropia.db" + + self.db_path = Path(db_path) + self._connection: Optional[sqlite3.Connection] = None + + # Ensure data directory exists + self.db_path.parent.mkdir(parents=True, exist_ok=True) + + logger.info(f"DatabaseManager initialized: {self.db_path}") + + def initialize(self) -> bool: + """ + Initialize database with schema. + + Returns: + True if successful, False otherwise + """ + try: + conn = self.get_connection() + + # Read and execute schema + schema_path = Path(__file__).parent / "schema.sql" + with open(schema_path, 'r') as f: + schema = f.read() + + # Execute schema (multiple statements) + conn.executescript(schema) + conn.commit() + + logger.info("Database schema initialized successfully") + return True + + except Exception as e: + logger.error(f"Failed to initialize database: {e}") + return False + + def get_connection(self) -> sqlite3.Connection: + """ + Get or create database connection. + + Returns: + SQLite connection with row factory and type detection + """ + if self._connection is None: + self._connection = sqlite3.connect( + self.db_path, + detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES + ) + self._connection.row_factory = sqlite3.Row + + # Enable foreign keys + self._connection.execute("PRAGMA foreign_keys = ON") + + # Performance optimizations (Rule #3: 60+ FPS) + self._connection.execute("PRAGMA journal_mode = WAL") + self._connection.execute("PRAGMA synchronous = NORMAL") + self._connection.execute("PRAGMA cache_size = -64000") # 64MB cache + + logger.debug("Database connection established") + + return self._connection + + def close(self) -> None: + """Close database connection.""" + if self._connection: + self._connection.close() + self._connection = None + logger.debug("Database connection closed") + + def execute(self, query: str, parameters: tuple = ()) -> sqlite3.Cursor: + """ + Execute a query with parameters. + + Args: + query: SQL query string + parameters: Query parameters (prevents SQL injection) + + Returns: + Cursor object + """ + conn = self.get_connection() + return conn.execute(query, parameters) + + def executemany(self, query: str, parameters: list) -> sqlite3.Cursor: + """ + Execute query with multiple parameter sets. + + Args: + query: SQL query string + parameters: List of parameter tuples + + Returns: + Cursor object + """ + conn = self.get_connection() + return conn.executemany(query, parameters) + + def commit(self) -> None: + """Commit current transaction.""" + if self._connection: + self._connection.commit() + + def rollback(self) -> None: + """Rollback current transaction.""" + if self._connection: + self._connection.rollback() + + def get_schema_version(self) -> int: + """ + Get current schema version. + + Returns: + Schema version number (0 if not initialized) + """ + try: + cursor = self.execute( + "SELECT MAX(version) FROM schema_version" + ) + result = cursor.fetchone() + return result[0] if result and result[0] else 0 + except sqlite3.OperationalError: + # Table doesn't exist + return 0 + + def backup(self, backup_path: Optional[str] = None) -> bool: + """ + Create database backup. + + Args: + backup_path: Path for backup file. Defaults to timestamped backup + + Returns: + True if successful + """ + try: + if backup_path is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_dir = self.db_path.parent / "backups" + backup_dir.mkdir(exist_ok=True) + backup_path = backup_dir / f"lemontropia_backup_{timestamp}.db" + + # Use SQLite backup API + source = self.get_connection() + dest = sqlite3.connect(backup_path) + + with dest: + source.backup(dest) + + dest.close() + logger.info(f"Database backed up to: {backup_path}") + return True + + except Exception as e: + logger.error(f"Backup failed: {e}") + return False + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit with automatic commit/rollback.""" + if exc_type is None: + self.commit() + else: + self.rollback() + self.close() + + +# ============================================================================ +# DECIMAL HANDLING (Rule #4: Precision) +# ============================================================================ + +def adapt_decimal(d: Decimal) -> str: + """Convert Decimal to string for SQLite storage.""" + return str(d) + +def convert_decimal(s: bytes) -> Decimal: + """Convert SQLite string back to Decimal.""" + return Decimal(s.decode('utf-8')) + +# Register Decimal adapter/converter +sqlite3.register_adapter(Decimal, adapt_decimal) +sqlite3.register_converter("DECIMAL", convert_decimal) + + +# ============================================================================ +# MODULE EXPORTS +# ============================================================================ + +__all__ = ['DatabaseManager', 'adapt_decimal', 'convert_decimal'] diff --git a/core/schema.sql b/core/schema.sql new file mode 100644 index 0000000..0758cb1 --- /dev/null +++ b/core/schema.sql @@ -0,0 +1,201 @@ +# Description: SQLite database schema for Lemontropia Suite +# Implements the Data Principle: Every session is a Project +# Schema version: 1.0.0 +# Created: 2026-02-08 + +-- Enable foreign key support +PRAGMA foreign_keys = ON; + +-- ============================================================================ +-- PROJECT MANAGEMENT (Data Principle Core) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('hunt', 'mine', 'craft', 'inventory')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'completed', 'archived')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP, + metadata TEXT -- JSON blob for extensible project data +); + +CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type); +CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); +CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at); + +-- ============================================================================ +-- SESSIONS (Active gameplay instances) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP, + duration_seconds INTEGER DEFAULT 0, + total_spent_ped REAL DEFAULT 0.0, -- Decimal stored as REAL, handled in code + total_return_ped REAL DEFAULT 0.0, + net_profit_ped REAL DEFAULT 0.0, + notes TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id); +CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); + +-- ============================================================================ +-- LOOT EVENTS (Core data capture) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS loot_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL CHECK (event_type IN ('global', 'hof', 'regular', 'skill')), + + -- Loot data + item_name TEXT, + quantity INTEGER DEFAULT 1, + value_ped REAL DEFAULT 0.0, + + -- Context + creature_name TEXT, -- For hunter module + zone_name TEXT, + + -- Raw log line for debugging/audit + raw_log_line TEXT, + + -- Screenshot reference (if triggered) + screenshot_path TEXT, + + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_loot_session ON loot_events(session_id); +CREATE INDEX IF NOT EXISTS idx_loot_timestamp ON loot_events(timestamp); +CREATE INDEX IF NOT EXISTS idx_loot_type ON loot_events(event_type); +CREATE INDEX IF NOT EXISTS idx_loot_value ON loot_events(value_ped); + +-- ============================================================================ +-- SKILL GAINS (Character progression tracking) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS skill_gains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + skill_name TEXT NOT NULL, + gained_amount REAL DEFAULT 0.0, + new_total REAL, + raw_log_line TEXT, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_skill_session ON skill_gains(session_id); +CREATE INDEX IF NOT EXISTS idx_skill_name ON skill_gains(skill_name); + +-- ============================================================================ +-- DECAY TRACKING (Weapon/tool durability) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS decay_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + item_name TEXT NOT NULL, + decay_amount_ped REAL DEFAULT 0.0, + shots_fired INTEGER DEFAULT 0, + raw_log_line TEXT, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_decay_session ON decay_events(session_id); + +-- ============================================================================ +-- SCREENSHOTS (Auto-capture on high-value events) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS screenshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + file_path TEXT NOT NULL, + trigger_event TEXT, -- What triggered the screenshot + trigger_value_ped REAL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_screenshots_session ON screenshots(session_id); + +-- ============================================================================ +-- APPLICATION STATE (Singleton table for app settings) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================ +-- DATABASE VERSION TRACKING +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + description TEXT +); + +-- Insert initial version +INSERT OR IGNORE INTO schema_version (version, description) +VALUES (1, 'Initial schema with Data Principle support'); + +-- ============================================================================ +-- VIEWS FOR COMMON QUERIES (Performance optimization) +-- ============================================================================ + +-- Active project summary +CREATE VIEW IF NOT EXISTS v_project_summary AS +SELECT + p.id, + p.name, + p.type, + p.status, + p.created_at, + COUNT(DISTINCT s.id) as session_count, + SUM(s.total_spent_ped) as total_spent, + SUM(s.total_return_ped) as total_return, + SUM(s.net_profit_ped) as net_profit, + SUM(CASE WHEN l.event_type = 'global' THEN 1 ELSE 0 END) as global_count, + SUM(CASE WHEN l.event_type = 'hof' THEN 1 ELSE 0 END) as hof_count +FROM projects p +LEFT JOIN sessions s ON p.id = s.project_id +LEFT JOIN loot_events l ON s.id = l.session_id +GROUP BY p.id; + +-- Session performance metrics +CREATE VIEW IF NOT EXISTS v_session_metrics AS +SELECT + s.id, + s.project_id, + p.name as project_name, + s.started_at, + s.ended_at, + s.duration_seconds, + s.total_spent_ped, + s.total_return_ped, + s.net_profit_ped, + CASE + WHEN s.total_spent_ped > 0 + THEN ROUND((s.net_profit_ped / s.total_spent_ped) * 100, 2) + ELSE 0 + END as roi_percent, + COUNT(l.id) as loot_events, + SUM(CASE WHEN l.event_type IN ('global', 'hof') THEN 1 ELSE 0 END) as notable_events +FROM sessions s +JOIN projects p ON s.project_id = p.id +LEFT JOIN loot_events l ON s.id = l.session_id +GROUP BY s.id;