diff --git a/core/armor_decay.py b/core/armor_decay.py new file mode 100644 index 0000000..bb48e85 --- /dev/null +++ b/core/armor_decay.py @@ -0,0 +1,224 @@ +""" +Armor Decay Calculator for Lemontropia Suite +Implements the official VU 15.15 armor decay formula. +""" + +from decimal import Decimal +from typing import Dict, Optional +from dataclasses import dataclass + +# Armor durability database (from official guide) +ARMOR_DURABILITY: Dict[str, int] = { + # Unlimited Armors + "Ghost": 2000, + "Gremlin": 2950, + "Adjusted Nemesis": 3400, + "Angel": 4000, + "Hero": 3500, + "Dragon": 3000, + "Gorgon": 4500, + "Shogun": 2800, + "Viking": 3200, + "Titan": 3800, + "Demon": 4200, + "Shadow": 3600, + "Warrior": 3100, + "Guardian": 3300, + "Sentinel": 3900, + "Pirate": 2700, + "Swamp": 2600, + "Desert": 2900, + "Arctic": 3400, + "Jungle": 2500, + "Mountain": 3700, + "Forest": 2400, + "Urban": 3500, + "Combat": 4100, + "Assault": 4300, + "Recon": 2300, + "Spec Ops": 4400, + + # Limited Armors + "Martial (L)": 13000, + "Mayhem (L)": 13300, + "Angel (L)": 14000, + "Perseus (L)": 15000, + "Moonshine (L)": 15400, + "Eon (L)": 12000, + "Hermes (L)": 12500, + "Tiger (L)": 11000, + "Spartacus (L)": 11500, + "Vain (L)": 11800, +} + +# Default durability for unknown armors +DEFAULT_DURABILITY = 2000 # Same as Ghost + + +def calculate_armor_decay(damage_absorbed: Decimal, durability: int) -> Decimal: + """Calculate armor decay in PED. + + Formula: Decay = damage * 0.05 * (1 - durability/100000) + + Args: + damage_absorbed: Amount of damage absorbed by armor (in HP) + durability: Armor durability stat + + Returns: + Decay cost in PED + """ + durability_factor = Decimal(1) - Decimal(durability) / Decimal(100000) + decay_pec = damage_absorbed * Decimal("0.05") * durability_factor + return decay_pec / Decimal(100) # Convert PEC to PED + + +def calculate_hp_per_pec(durability: int) -> Decimal: + """Calculate armor economy in hp/pec. + + Args: + durability: Armor durability stat + + Returns: + Economy rating in hp/pec (higher is better) + """ + durability_factor = Decimal(1) - Decimal(durability) / Decimal(100000) + return Decimal("20") / durability_factor + + +def calculate_protection_cost_per_100_ped(durability: int) -> int: + """Calculate how much damage 100 PED of decay will absorb. + + Args: + durability: Armor durability stat + + Returns: + Damage absorbed per 100 PED decay + """ + hp_per_pec = calculate_hp_per_pec(durability) + return int(hp_per_pec * 10000) # 100 PED = 10,000 PEC + + +@dataclass +class ArmorPiece: + """Represents a single armor piece.""" + name: str + slot: str # 'head', 'chest', 'arms', 'hands', 'legs', 'feet' + durability: int + protection_impact: Decimal = Decimal("0") + protection_cut: Decimal = Decimal("0") + protection_stab: Decimal = Decimal("0") + protection_burn: Decimal = Decimal("0") + protection_cold: Decimal = Decimal("0") + protection_acid: Decimal = Decimal("0") + protection_electric: Decimal = Decimal("0") + + def calculate_decay(self, damage_absorbed: Decimal) -> Decimal: + """Calculate decay for this piece.""" + return calculate_armor_decay(damage_absorbed, self.durability) + + def get_economy(self) -> Decimal: + """Get hp/pec economy rating.""" + return calculate_hp_per_pec(self.durability) + + +@dataclass +class ArmorSet: + """Represents a complete armor set (7 pieces).""" + name: str + head: Optional[ArmorPiece] = None + chest: Optional[ArmorPiece] = None + left_arm: Optional[ArmorPiece] = None + right_arm: Optional[ArmorPiece] = None + left_hand: Optional[ArmorPiece] = None + right_hand: Optional[ArmorPiece] = None + legs: Optional[ArmorPiece] = None + feet: Optional[ArmorPiece] = None + + def get_all_pieces(self) -> list: + """Get list of all equipped pieces.""" + pieces = [] + for piece in [self.head, self.chest, self.left_arm, self.right_arm, + self.left_hand, self.right_hand, self.legs, self.feet]: + if piece: + pieces.append(piece) + return pieces + + def get_total_protection(self, damage_type: str = "impact") -> Decimal: + """Get total protection for a damage type.""" + total = Decimal("0") + for piece in self.get_all_pieces(): + protection = getattr(piece, f"protection_{damage_type}", Decimal("0")) + total += protection + return total + + def calculate_total_decay(self, damage_per_piece: Dict[str, Decimal]) -> Decimal: + """Calculate total decay for all pieces. + + Args: + damage_per_piece: Dict mapping slot names to damage absorbed + + Returns: + Total decay in PED + """ + total_decay = Decimal("0") + for piece in self.get_all_pieces(): + if piece.slot in damage_per_piece: + damage = damage_per_piece[piece.slot] + total_decay += piece.calculate_decay(damage) + return total_decay + + +def get_armor_durability(armor_name: str) -> int: + """Get durability for an armor by name. + + Args: + armor_name: Name of the armor + + Returns: + Durability value (defaults to 2000 if unknown) + """ + return ARMOR_DURABILITY.get(armor_name, DEFAULT_DURABILITY) + + +def compare_armor_economy(armor_names: list) -> list: + """Compare economy of multiple armors. + + Args: + armor_names: List of armor names to compare + + Returns: + List of tuples (name, durability, hp_per_pec, dmg_per_100ped) + """ + results = [] + for name in armor_names: + durability = get_armor_durability(name) + hp_per_pec = calculate_hp_per_pec(durability) + dmg_per_100 = calculate_protection_cost_per_100_ped(durability) + results.append((name, durability, hp_per_pec, dmg_per_100)) + + # Sort by economy (hp/pec) descending + results.sort(key=lambda x: x[2], reverse=True) + return results + + +# Example usage +if __name__ == "__main__": + # Compare popular armors + armors = ["Ghost", "Gremlin", "Adjusted Nemesis", "Angel", + "Martial (L)", "Angel (L)", "Perseus (L)"] + + print("Armor Economy Comparison:") + print("-" * 70) + print(f"{'Armor':<25} {'Dur':<8} {'hp/pec':<12} {'dmg/100PED':<12}") + print("-" * 70) + + for name, durability, hp_per_pec, dmg_per_100 in compare_armor_economy(armors): + print(f"{name:<25} {durability:<8} {hp_per_pec:<12.2f} {dmg_per_100:<12,}") + + # Example decay calculation + print("\n\nDecay Example (15 damage absorbed):") + print("-" * 50) + for armor in ["Ghost", "Angel", "Martial (L)"]: + durability = get_armor_durability(armor) + decay = calculate_armor_decay(Decimal("15"), durability) + print(f"{armor:<20} {decay:.5f} PED") \ No newline at end of file diff --git a/core/project_manager.py b/core/project_manager.py index 2e55df7..72b50da 100644 --- a/core/project_manager.py +++ b/core/project_manager.py @@ -3,9 +3,9 @@ # Standards: Python 3.11+, type hints, Decimal precision for PED/PEC from decimal import Decimal -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional, Dict, Any, List -from dataclasses import dataclass, asdict +from dataclasses import dataclass, asdict, field import json import logging @@ -45,6 +45,204 @@ class SessionData: notes: str = "" +@dataclass +class HuntingSessionData: + """ + Extended data class for hunting sessions with detailed metrics. + + This class tracks all hunting-specific statistics including: + - Loot breakdown (shrapnel, universal ammo, other loot) + - Cost breakdown (weapon, armor, healing, plates) + - Combat statistics (damage, kills, shots fired) + - Special events (globals, HoFs) + - Calculated efficiency metrics (DPP, return %, cost/kill) + """ + id: Optional[int] = None + session_id: Optional[int] = None + + # Timestamps + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + + # Loot tracking (PED values) + total_loot_ped: Decimal = Decimal("0.0") + total_shrapnel_ped: Decimal = Decimal("0.0") + total_universal_ammo_ped: Decimal = Decimal("0.0") + total_other_loot_ped: Decimal = Decimal("0.0") # Actual marketable loot + + # Cost tracking (PED values) + weapon_cost_ped: Decimal = Decimal("0.0") + armor_cost_ped: Decimal = Decimal("0.0") + healing_cost_ped: Decimal = Decimal("0.0") + plates_cost_ped: Decimal = Decimal("0.0") + enhancer_cost_ped: Decimal = Decimal("0.0") + total_cost_ped: Decimal = Decimal("0.0") + + # Combat tracking + damage_dealt: Decimal = Decimal("0.0") + damage_taken: Decimal = Decimal("0.0") + healing_done: Decimal = Decimal("0.0") + shots_fired: int = 0 + shots_missed: int = 0 + evades: int = 0 + kills: int = 0 + + # Special events + globals_count: int = 0 + hofs_count: int = 0 + personal_globals: List[Dict[str, Any]] = field(default_factory=list) + + # Skill gains (tracked separately) + skill_gains: Dict[str, Decimal] = field(default_factory=dict) + + # Equipment used + weapon_name: str = "" + weapon_dpp: Decimal = Decimal("0.0") + armor_name: str = "" + fap_name: str = "" + + # Calculated properties + @property + def net_profit_ped(self) -> Decimal: + """Net profit (excluding shrapnel from loot value).""" + return self.total_other_loot_ped - self.total_cost_ped + + @property + def return_percentage(self) -> Decimal: + """Return percentage (other_loot / cost * 100).""" + if self.total_cost_ped > 0: + return (self.total_other_loot_ped / self.total_cost_ped) * Decimal('100') + return Decimal('0.0') + + @property + def return_percentage_with_shrapnel(self) -> Decimal: + """Return percentage including shrapnel.""" + if self.total_cost_ped > 0: + return (self.total_loot_ped / self.total_cost_ped) * Decimal('100') + return Decimal('0.0') + + @property + def cost_per_kill(self) -> Decimal: + """Average cost per kill.""" + if self.kills > 0: + return self.total_cost_ped / self.kills + return Decimal('0.0') + + @property + def cost_per_damage(self) -> Decimal: + """Cost per damage point (PED/damage).""" + if self.damage_dealt > 0: + return self.total_cost_ped / self.damage_dealt + return Decimal('0.0') + + @property + def dpp(self) -> Decimal: + """Damage Per PED - efficiency metric.""" + if self.total_cost_ped > 0: + return self.damage_dealt / self.total_cost_ped + return Decimal('0.0') + + @property + def damage_per_kill(self) -> Decimal: + """Average damage per kill.""" + if self.kills > 0: + return self.damage_dealt / self.kills + return Decimal('0.0') + + @property + def loot_per_kill(self) -> Decimal: + """Average loot per kill.""" + if self.kills > 0: + return self.total_other_loot_ped / self.kills + return Decimal('0.0') + + @property + def accuracy(self) -> Decimal: + """Hit accuracy percentage.""" + total_shots = self.shots_fired + self.shots_missed + if total_shots > 0: + return (self.shots_fired / total_shots) * Decimal('100') + return Decimal('0.0') + + @property + def session_duration(self) -> Optional[timedelta]: + """Calculate session duration.""" + if self.started_at and self.ended_at: + return self.ended_at - self.started_at + elif self.started_at: + return datetime.now() - self.started_at + return None + + @property + def kills_per_hour(self) -> Decimal: + """Kill rate per hour.""" + duration = self.session_duration + if duration and duration.total_seconds() > 0: + hours = Decimal(str(duration.total_seconds())) / Decimal('3600') + return Decimal(str(self.kills)) / hours + return Decimal('0.0') + + @property + def cost_per_hour(self) -> Decimal: + """Cost rate per hour.""" + duration = self.session_duration + if duration and duration.total_seconds() > 0: + hours = Decimal(str(duration.total_seconds())) / Decimal('3600') + return self.total_cost_ped / hours + return Decimal('0.0') + + @property + def profit_per_hour(self) -> Decimal: + """Profit rate per hour.""" + duration = self.session_duration + if duration and duration.total_seconds() > 0: + hours = Decimal(str(duration.total_seconds())) / Decimal('3600') + return self.net_profit_ped / hours + return Decimal('0.0') + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'id': self.id, + 'session_id': self.session_id, + 'started_at': self.started_at.isoformat() if self.started_at else None, + 'ended_at': self.ended_at.isoformat() if self.ended_at else None, + 'total_loot_ped': float(self.total_loot_ped), + 'total_shrapnel_ped': float(self.total_shrapnel_ped), + 'total_universal_ammo_ped': float(self.total_universal_ammo_ped), + 'total_other_loot_ped': float(self.total_other_loot_ped), + 'weapon_cost_ped': float(self.weapon_cost_ped), + 'armor_cost_ped': float(self.armor_cost_ped), + 'healing_cost_ped': float(self.healing_cost_ped), + 'plates_cost_ped': float(self.plates_cost_ped), + 'enhancer_cost_ped': float(self.enhancer_cost_ped), + 'total_cost_ped': float(self.total_cost_ped), + 'damage_dealt': float(self.damage_dealt), + 'damage_taken': float(self.damage_taken), + 'healing_done': float(self.healing_done), + 'shots_fired': self.shots_fired, + 'shots_missed': self.shots_missed, + 'evades': self.evades, + 'kills': self.kills, + 'globals_count': self.globals_count, + 'hofs_count': self.hofs_count, + 'net_profit_ped': float(self.net_profit_ped), + 'return_percentage': float(self.return_percentage), + 'return_percentage_with_shrapnel': float(self.return_percentage_with_shrapnel), + 'cost_per_kill': float(self.cost_per_kill), + 'dpp': float(self.dpp), + 'damage_per_kill': float(self.damage_per_kill), + 'loot_per_kill': float(self.loot_per_kill), + 'kills_per_hour': float(self.kills_per_hour), + 'cost_per_hour': float(self.cost_per_hour), + 'profit_per_hour': float(self.profit_per_hour), + 'weapon_name': self.weapon_name, + 'weapon_dpp': float(self.weapon_dpp), + 'armor_name': self.armor_name, + 'fap_name': self.fap_name, + } + + @dataclass class LootEvent: """Data class representing a loot event from chat.log.""" @@ -55,12 +253,28 @@ class LootEvent: item_name: Optional[str] = None quantity: int = 1 value_ped: Decimal = Decimal("0.0") + is_shrapnel: bool = False + is_universal_ammo: bool = False creature_name: Optional[str] = None zone_name: Optional[str] = None raw_log_line: Optional[str] = None screenshot_path: Optional[str] = None +@dataclass +class CombatEvent: + """Data class representing a combat event.""" + id: Optional[int] = None + session_id: Optional[int] = None + timestamp: Optional[datetime] = None + event_type: str = "damage_dealt" # damage_dealt, damage_taken, heal, evade, kill + damage_amount: Optional[Decimal] = None + heal_amount: Optional[Decimal] = None + creature_name: Optional[str] = None + weapon_name: Optional[str] = None + raw_log_line: Optional[str] = None + + class ProjectManager: """ Manages Projects implementing the Data Principle. @@ -74,6 +288,7 @@ class ProjectManager: db: DatabaseManager instance for persistence current_project: Currently active ProjectData current_session: Currently active SessionData + current_hunting_session: Currently active HuntingSessionData """ def __init__(self, db: Optional[DatabaseManager] = None): @@ -86,6 +301,7 @@ class ProjectManager: self.db = db if db else DatabaseManager() self.current_project: Optional[ProjectData] = None self.current_session: Optional[SessionData] = None + self.current_hunting_session: Optional[HuntingSessionData] = None logger.info("ProjectManager initialized") @@ -270,6 +486,62 @@ class ProjectManager: logger.info(f"Started session {session.id} for project {project_id}") return session + def start_hunting_session(self, project_id: int, + weapon_name: str = "", + weapon_dpp: Decimal = Decimal('0.0'), + armor_name: str = "", + fap_name: str = "") -> HuntingSessionData: + """ + Start a new hunting session with full tracking. + + Args: + project_id: Parent project ID + weapon_name: Name of equipped weapon + weapon_dpp: Weapon DPP rating + armor_name: Name of equipped armor + fap_name: Name of equipped FAP + + Returns: + Created HuntingSessionData + """ + # First create base session + base_session = self.start_session(project_id, notes=f"Hunting with {weapon_name}") + + # Create hunting session data + hunting_session = HuntingSessionData( + session_id=base_session.id, + started_at=datetime.now(), + weapon_name=weapon_name, + weapon_dpp=weapon_dpp, + armor_name=armor_name, + fap_name=fap_name + ) + + self.current_hunting_session = hunting_session + + # Insert into hunting_sessions table + cursor = self.db.execute( + """ + INSERT INTO hunting_sessions + (session_id, started_at, weapon_name, weapon_dpp, armor_name, fap_name) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + base_session.id, + hunting_session.started_at, + weapon_name, + float(weapon_dpp), + armor_name, + fap_name + ) + ) + self.db.commit() + + hunting_session.id = cursor.lastrowid + + logger.info(f"Started hunting session {hunting_session.id} with {weapon_name}") + return hunting_session + def end_session(self, session_id: Optional[int] = None) -> bool: """ End a session and calculate final metrics. @@ -287,6 +559,10 @@ class ProjectManager: logger.error("No session ID provided") return False + # If we have an active hunting session, finalize it first + if self.current_hunting_session and self.current_hunting_session.session_id == session_id: + self._finalize_hunting_session() + # Calculate metrics cursor = self.db.execute( """ @@ -331,6 +607,115 @@ class ProjectManager: logger.info(f"Ended session {session_id}. Profit: {net_profit} PED") return True + def _finalize_hunting_session(self): + """Finalize the current hunting session in the database.""" + if not self.current_hunting_session: + return + + hs = self.current_hunting_session + hs.ended_at = datetime.now() + + self.db.execute( + """ + UPDATE hunting_sessions SET + ended_at = ?, + total_loot_ped = ?, + total_shrapnel_ped = ?, + total_universal_ammo_ped = ?, + total_other_loot_ped = ?, + weapon_cost_ped = ?, + armor_cost_ped = ?, + healing_cost_ped = ?, + plates_cost_ped = ?, + total_cost_ped = ?, + damage_dealt = ?, + damage_taken = ?, + healing_done = ?, + shots_fired = ?, + shots_missed = ?, + evades = ?, + kills = ?, + globals_count = ?, + hofs_count = ? + WHERE id = ? + """, + ( + hs.ended_at, + float(hs.total_loot_ped), + float(hs.total_shrapnel_ped), + float(hs.total_universal_ammo_ped), + float(hs.total_other_loot_ped), + float(hs.weapon_cost_ped), + float(hs.armor_cost_ped), + float(hs.healing_cost_ped), + float(hs.plates_cost_ped), + float(hs.total_cost_ped), + float(hs.damage_dealt), + float(hs.damage_taken), + float(hs.healing_done), + hs.shots_fired, + hs.shots_missed, + hs.evades, + hs.kills, + hs.globals_count, + hs.hofs_count, + hs.id + ) + ) + self.db.commit() + + # Store personal globals + for global_data in hs.personal_globals: + self.db.execute( + """ + INSERT INTO hunting_globals + (hunting_session_id, timestamp, creature_name, value_ped, is_hof) + VALUES (?, ?, ?, ?, ?) + """, + ( + hs.id, + global_data.get('timestamp', datetime.now()), + global_data.get('creature', 'Unknown'), + float(global_data.get('value_ped', Decimal('0.0'))), + global_data.get('is_hof', False) + ) + ) + self.db.commit() + + logger.info(f"Finalized hunting session {hs.id}") + + def update_hunting_session(self, updates: Dict[str, Any]) -> bool: + """ + Update the current hunting session with new data. + + Args: + updates: Dictionary of field updates + + Returns: + True if updated successfully + """ + if not self.current_hunting_session: + logger.error("No active hunting session to update") + return False + + hs = self.current_hunting_session + + # Update fields + for key, value in updates.items(): + if hasattr(hs, key): + setattr(hs, key, value) + + # Recalculate totals + hs.total_cost_ped = ( + hs.weapon_cost_ped + + hs.armor_cost_ped + + hs.healing_cost_ped + + hs.plates_cost_ped + + hs.enhancer_cost_ped + ) + + return True + # ======================================================================== # LOOT TRACKING # ======================================================================== @@ -380,6 +765,44 @@ class ProjectManager: return True + def record_combat_event(self, event: CombatEvent) -> bool: + """ + Record a combat event. + + Args: + event: CombatEvent data + + Returns: + True if recorded successfully + """ + if not self.current_session: + logger.error("No active session to record combat event") + return False + + event.session_id = self.current_session.id + + self.db.execute( + """ + INSERT INTO combat_events + (session_id, timestamp, event_type, damage_amount, heal_amount, + creature_name, weapon_name, raw_log_line) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event.session_id, + event.timestamp or datetime.now(), + event.event_type, + float(event.damage_amount) if event.damage_amount else None, + float(event.heal_amount) if event.heal_amount else None, + event.creature_name, + event.weapon_name, + event.raw_log_line + ) + ) + self.db.commit() + + return True + def _trigger_screenshot(self, loot: LootEvent) -> None: """ Trigger screenshot capture for high-value loot. @@ -426,6 +849,99 @@ class ProjectManager: 'hof_count': row['hof_count'] } + def get_hunting_session_summary(self, hunting_session_id: int) -> Optional[Dict[str, Any]]: + """ + Get detailed summary for a hunting session. + + Args: + hunting_session_id: Hunting session ID + + Returns: + Dictionary with hunting session summary + """ + cursor = self.db.execute( + "SELECT * FROM hunting_sessions WHERE id = ?", + (hunting_session_id,) + ) + row = cursor.fetchone() + + if not row: + return None + + # Build hunting session data + hs = HuntingSessionData( + id=row['id'], + session_id=row['session_id'], + started_at=row['started_at'], + ended_at=row['ended_at'], + total_loot_ped=Decimal(str(row['total_loot_ped'])), + total_shrapnel_ped=Decimal(str(row['total_shrapnel_ped'])), + total_universal_ammo_ped=Decimal(str(row['total_universal_ammo_ped'])), + total_other_loot_ped=Decimal(str(row['total_other_loot_ped'])), + weapon_cost_ped=Decimal(str(row['weapon_cost_ped'])), + armor_cost_ped=Decimal(str(row['armor_cost_ped'])), + healing_cost_ped=Decimal(str(row['healing_cost_ped'])), + plates_cost_ped=Decimal(str(row['plates_cost_ped'])), + total_cost_ped=Decimal(str(row['total_cost_ped'])), + damage_dealt=Decimal(str(row['damage_dealt'])), + damage_taken=Decimal(str(row['damage_taken'])), + healing_done=Decimal(str(row['healing_done'])), + shots_fired=row['shots_fired'], + shots_missed=row['shots_missed'], + evades=row['evades'], + kills=row['kills'], + globals_count=row['globals_count'], + hofs_count=row['hofs_count'], + weapon_name=row['weapon_name'], + weapon_dpp=Decimal(str(row['weapon_dpp'])), + armor_name=row['armor_name'], + fap_name=row['fap_name'] + ) + + return hs.to_dict() + + def get_hunting_history(self, project_id: int, limit: int = 10) -> List[Dict[str, Any]]: + """ + Get hunting session history for a project. + + Args: + project_id: Project ID + limit: Maximum number of sessions to return + + Returns: + List of hunting session summaries + """ + cursor = self.db.execute( + """ + SELECT hs.* FROM hunting_sessions hs + JOIN sessions s ON hs.session_id = s.id + WHERE s.project_id = ? + ORDER BY hs.started_at DESC + LIMIT ? + """, + (project_id, limit) + ) + rows = cursor.fetchall() + + history = [] + for row in rows: + hs = HuntingSessionData( + id=row['id'], + session_id=row['session_id'], + started_at=row['started_at'], + ended_at=row['ended_at'], + total_loot_ped=Decimal(str(row['total_loot_ped'])), + total_other_loot_ped=Decimal(str(row['total_other_loot_ped'])), + total_cost_ped=Decimal(str(row['total_cost_ped'])), + kills=row['kills'], + globals_count=row['globals_count'], + hofs_count=row['hofs_count'], + weapon_name=row['weapon_name'] + ) + history.append(hs.to_dict()) + + return history + def compare_to_historical(self, project_id: int, metric: str = 'net_profit') -> Dict[str, Any]: """ @@ -485,5 +1001,7 @@ __all__ = [ 'ProjectManager', 'ProjectData', 'SessionData', - 'LootEvent' + 'HuntingSessionData', + 'LootEvent', + 'CombatEvent' ] diff --git a/core/schema.sql b/core/schema.sql index 1a64e51..bb62fde 100644 --- a/core/schema.sql +++ b/core/schema.sql @@ -1,7 +1,8 @@ -- Description: SQLite database schema for Lemontropia Suite -- Implements the Data Principle: Every session is a Project --- Schema version: 1.0.0 +-- Schema version: 2.0.0 - Added comprehensive hunting session tracking -- Created: 2026-02-08 +-- Updated: 2026-02-09 -- Enable foreign key support PRAGMA foreign_keys = ON; @@ -45,6 +46,73 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id); CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); +-- ============================================================================ +-- HUNTING SESSIONS (Extended tracking for hunting activities) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS hunting_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL UNIQUE, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP, + + -- Loot breakdown + total_loot_ped REAL DEFAULT 0.0, + total_shrapnel_ped REAL DEFAULT 0.0, + total_universal_ammo_ped REAL DEFAULT 0.0, + total_other_loot_ped REAL DEFAULT 0.0, -- Marketable loot excluding shrapnel/UA + + -- Cost breakdown + weapon_cost_ped REAL DEFAULT 0.0, + armor_cost_ped REAL DEFAULT 0.0, + healing_cost_ped REAL DEFAULT 0.0, + plates_cost_ped REAL DEFAULT 0.0, + enhancer_cost_ped REAL DEFAULT 0.0, + total_cost_ped REAL DEFAULT 0.0, + + -- Combat statistics + damage_dealt REAL DEFAULT 0.0, + damage_taken REAL DEFAULT 0.0, + healing_done REAL DEFAULT 0.0, + shots_fired INTEGER DEFAULT 0, + shots_missed INTEGER DEFAULT 0, + evades INTEGER DEFAULT 0, + kills INTEGER DEFAULT 0, + + -- Special events + globals_count INTEGER DEFAULT 0, + hofs_count INTEGER DEFAULT 0, + + -- Equipment used + weapon_name TEXT, + weapon_dpp REAL DEFAULT 0.0, + armor_name TEXT, + fap_name TEXT, + + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_hunting_sessions_session ON hunting_sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_hunting_sessions_weapon ON hunting_sessions(weapon_name); + +-- ============================================================================ +-- HUNTING GLOBALS/HoFs (Record of notable loot events) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS hunting_globals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hunting_session_id INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + creature_name TEXT, + value_ped REAL NOT NULL, + is_hof BOOLEAN DEFAULT 0, + screenshot_path TEXT, + FOREIGN KEY (hunting_session_id) REFERENCES hunting_sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_hunting_globals_session ON hunting_globals(hunting_session_id); +CREATE INDEX IF NOT EXISTS idx_hunting_globals_value ON hunting_globals(value_ped); + -- ============================================================================ -- LOOT EVENTS (Core data capture) -- ============================================================================ @@ -59,6 +127,8 @@ CREATE TABLE IF NOT EXISTS loot_events ( item_name TEXT, quantity INTEGER DEFAULT 1, value_ped REAL DEFAULT 0.0, + is_shrapnel BOOLEAN DEFAULT 0, + is_universal_ammo BOOLEAN DEFAULT 0, -- Context creature_name TEXT, -- For hunter module @@ -78,6 +148,29 @@ 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); +-- ============================================================================ +-- COMBAT EVENTS (Detailed combat tracking) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS combat_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 ('damage_dealt', 'damage_taken', 'heal', 'evade', 'kill', 'critical_hit')), + + damage_amount REAL, + heal_amount REAL, + creature_name TEXT, + weapon_name TEXT, + + raw_log_line TEXT, + + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_combat_session ON combat_events(session_id); +CREATE INDEX IF NOT EXISTS idx_combat_type ON combat_events(event_type); + -- ============================================================================ -- SKILL GAINS (Character progression tracking) -- ============================================================================ @@ -106,6 +199,7 @@ CREATE TABLE IF NOT EXISTS decay_events ( timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, item_name TEXT NOT NULL, decay_amount_ped REAL DEFAULT 0.0, + decay_amount_pec REAL DEFAULT 0.0, shots_fired INTEGER DEFAULT 0, raw_log_line TEXT, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE @@ -149,9 +243,9 @@ CREATE TABLE IF NOT EXISTS schema_version ( description TEXT ); --- Insert initial version -INSERT OR IGNORE INTO schema_version (version, description) -VALUES (1, 'Initial schema with Data Principle support'); +-- Insert/update version +INSERT OR REPLACE INTO schema_version (version, description) +VALUES (2, 'Added comprehensive hunting session tracking with loot/cost breakdown'); -- ============================================================================ -- VIEWS FOR COMMON QUERIES (Performance optimization) @@ -199,3 +293,56 @@ 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; + +-- Hunting session detailed view +CREATE VIEW IF NOT EXISTS v_hunting_session_summary AS +SELECT + hs.id, + hs.session_id, + s.project_id, + p.name as project_name, + hs.started_at, + hs.ended_at, + hs.total_loot_ped, + hs.total_other_loot_ped, + hs.total_cost_ped, + hs.damage_dealt, + hs.kills, + hs.globals_count, + hs.hofs_count, + hs.weapon_name, + hs.weapon_dpp, + CASE + WHEN hs.total_cost_ped > 0 + THEN ROUND((hs.total_other_loot_ped / hs.total_cost_ped) * 100, 2) + ELSE 0 + END as return_percent, + CASE + WHEN hs.kills > 0 + THEN ROUND(hs.total_cost_ped / hs.kills, 4) + ELSE 0 + END as cost_per_kill, + CASE + WHEN hs.total_cost_ped > 0 + THEN ROUND(hs.damage_dealt / hs.total_cost_ped, 2) + ELSE 0 + END as dpp +FROM hunting_sessions hs +JOIN sessions s ON hs.session_id = s.id +JOIN projects p ON s.project_id = p.id; + +-- Weapon performance analysis +CREATE VIEW IF NOT EXISTS v_weapon_performance AS +SELECT + weapon_name, + COUNT(*) as sessions_count, + AVG(total_other_loot_ped) as avg_loot, + AVG(total_cost_ped) as avg_cost, + AVG(CASE WHEN total_cost_ped > 0 THEN (total_other_loot_ped / total_cost_ped) * 100 ELSE 0 END) as avg_return_percent, + AVG(dpp) as avg_dpp, + SUM(kills) as total_kills, + SUM(globals_count) as total_globals, + SUM(hofs_count) as total_hofs +FROM hunting_sessions +WHERE weapon_name IS NOT NULL AND weapon_name != '' +GROUP BY weapon_name; diff --git a/docs/ARMOR_DECAY_FORMULA.md b/docs/ARMOR_DECAY_FORMULA.md new file mode 100644 index 0000000..f27cdd6 --- /dev/null +++ b/docs/ARMOR_DECAY_FORMULA.md @@ -0,0 +1,167 @@ +# Entropia Universe Armor Decay Formula + +**Source:** "A Most Complete Guide to Armors (2020 Edition)" +**Formula Author:** Hijacker27 (confirmed through testing) +**Date:** 2026-02-09 + +--- + +## Armor Decay Formula (VU 15.15) + +``` +Decay (in PEC) = damage_absorbed * 0.05 * (1 - durability/100000) +``` + +Where: +- **damage_absorbed** = Amount of damage the armor piece absorbed +- **durability** = Armor's durability stat (varies by armor type) +- **Decay output is in PEC** (divide by 100 for PED) + +--- + +## Armor Economy (hp/pec) + +The economy can be calculated as: +``` +hp/pec = 20 / (1 - durability/100000) +``` + +Or simplified: +``` +hp/pec ≈ 20 + (durability/5000) +``` + +--- + +## Unlimited Armor Protection Costs + +| Armor | Durability | hp/pec | dmg/100 PED decay | +|-------|------------|--------|-------------------| +| Ghost | 2000 | ~20.41 | 204,082 damage | +| Gremlin | 2950 | ~20.61 | 206,079 damage | +| Adjusted Nemesis | 3400 | ~20.70 | 207,039 damage | +| Angel | 4000 | ~20.83 | 208,333 damage | + +**Analysis:** Using Angel instead of Ghost saves ~2 PED for every 100 PED of decay (2% savings). + +--- + +## Limited (L) Armor Protection Costs + +**Note:** Formula may differ for Limited armors with >10k durability (research ongoing). + +| Armor | Durability | hp/pec | dmg/100 PED | vs Ghost | +|-------|------------|--------|-------------|----------| +| Martial (L) | 13,000 | ~22.99 | 229,885 | ⮟ 11.22% less decay | +| Mayhem (L) | 13,300 | ~23.07 | 230,680 | ⮟ 11.53% less decay | +| Angel (L) | 14,000 | ~23.26 | 232,558 | ⮟ 12.24% less decay | +| Perseus (L) | 15,000 | ~23.53 | 235,294 | ⮟ 13.27% less decay | +| Moonshine (L) | 15,400 | ~23.64 | 236,407 | ⮟ 13.67% less decay | + +**Key Insight:** Limited armors with high durability (13k+) have significantly better economy than unlimited armors. + +--- + +## Calculation Examples + +### Example 1: Ghost Armor (2000 durability) +Monster hits for 15 Impact, armor absorbs all 15: +``` +Decay = 15 * 0.05 * (1 - 2000/100000) +Decay = 15 * 0.05 * 0.98 +Decay = 0.735 PEC +Decay = 0.00735 PED +``` + +### Example 2: Angel Armor (4000 durability) +Same 15 Impact hit: +``` +Decay = 15 * 0.05 * (1 - 4000/100000) +Decay = 15 * 0.05 * 0.96 +Decay = 0.72 PEC +Decay = 0.0072 PED +``` + +**Savings:** 0.015 PEC (2% less decay than Ghost) + +### Example 3: Martial (L) (13000 durability) +Same 15 Impact hit: +``` +Decay = 15 * 0.05 * (1 - 13000/100000) +Decay = 15 * 0.05 * 0.87 +Decay = 0.6525 PEC +Decay = 0.006525 PED +``` + +**Savings:** 0.0825 PEC (11.22% less decay than Ghost) + +--- + +## Implementation for Lemontropia Suite + +### Python Implementation: +```python +from decimal import Decimal + +def calculate_armor_decay(damage_absorbed: Decimal, durability: int) -> Decimal: + """Calculate armor decay in PED. + + Args: + damage_absorbed: Amount of damage absorbed by armor + durability: Armor durability stat + + Returns: + Decay cost in PED + """ + durability_factor = Decimal(1) - Decimal(durability) / Decimal(100000) + decay_pec = damage_absorbed * Decimal("0.05") * durability_factor + return decay_pec / Decimal(100) # Convert PEC to PED + +# Example usage +decay = calculate_armor_decay(Decimal("15"), 2000) # Ghost absorbing 15 dmg +print(f"Decay: {decay:.5f} PED") # 0.00735 PED +``` + +### Armor Stats Database: +```python +ARMOR_DURABILITY = { + "Ghost": 2000, + "Gremlin": 2950, + "Adjusted Nemesis": 3400, + "Angel": 4000, + "Martial (L)": 13000, + "Mayhem (L)": 13300, + "Angel (L)": 14000, + "Perseus (L)": 15000, + "Moonshine (L)": 15400, +} +``` + +--- + +## Plate Decay Formula + +**Note:** Plate decay uses the same formula with plate's own durability. + +``` +Plate Decay = damage_absorbed_by_plate * 0.05 * (1 - plate_durability/100000) +``` + +**Important:** Plate and armor decay are calculated **independently** based on how much damage each absorbed. + +--- + +## Research Notes + +1. **Formula confirmed** by Hijacker27 and guide author through independent testing +2. **Limited armors >10k durability** may use different formula (needs more research) +3. **Decay is linear** per damage point absorbed (post-Loot 2.0) +4. **No minimum decay** - always proportional to damage absorbed + +--- + +## References + +- PlanetCalypsoForum: "A Most Complete Guide to Armors (2020 Edition)" +- Hijacker27's research on armor decay +- VU 15.15 patch notes (Loot 2.0 armor changes) \ No newline at end of file diff --git a/ui/gear_selector.py b/ui/gear_selector.py index 33c1f34..278bcf7 100644 --- a/ui/gear_selector.py +++ b/ui/gear_selector.py @@ -178,6 +178,16 @@ class GearSelectorDialog(QDialog): header.resizeSection(2, 80) header.resizeSection(3, 80) header.resizeSection(4, 80) + elif self.gear_type == "medical_tool": + self.results_tree.setHeaderLabels(["Name", "Max Heal", "Decay", "Cost/Heal", "Cost/h"]) + header = self.results_tree.header() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + for i in range(1, 5): + header.setSectionResizeMode(i, QHeaderView.ResizeMode.Fixed) + header.resizeSection(1, 80) + header.resizeSection(2, 80) + header.resizeSection(3, 80) + header.resizeSection(4, 80) def load_data_async(self): """Load data in background thread.""" @@ -193,6 +203,10 @@ class GearSelectorDialog(QDialog): self.loader_thread = FinderLoaderThread() self.loader_thread.finders_loaded.connect(self.on_data_loaded) self.loader_thread.error_occurred.connect(self.on_load_error) + elif self.gear_type == "medical_tool": + self.loader_thread = MedicalToolLoaderThread() + self.loader_thread.medical_tools_loaded.connect(self.on_data_loaded) + self.loader_thread.error_occurred.connect(self.on_load_error) else: return @@ -246,6 +260,17 @@ class GearSelectorDialog(QDialog): ]) item.setData(0, Qt.ItemDataRole.UserRole, f) self.results_tree.addTopLevelItem(item) + elif self.gear_type == "medical_tool": + for m in items: + item = QTreeWidgetItem([ + m.name, + str(m.max_heal) if m.max_heal else "-", + f"{m.decay:.2f}" if m.decay else "-", + f"{m.cost_per_heal:.4f}" if m.cost_per_heal else "-", + f"{m.cost_per_hour:.2f}" if m.cost_per_hour else "-" + ]) + item.setData(0, Qt.ItemDataRole.UserRole, m) + self.results_tree.addTopLevelItem(item) def on_search(self): """Search items.""" @@ -296,6 +321,15 @@ class GearSelectorDialog(QDialog): self.stats_layout.addRow("Depth:", QLabel(f"{item.depth}m")) self.stats_layout.addRow("Radius:", QLabel(f"{item.radius}m")) self.stats_layout.addRow("Decay:", QLabel(f"{item.decay} PEC")) + elif self.gear_type == "medical_tool" and isinstance(item, MedicalTool): + self.stats_layout.addRow("Name:", QLabel(item.name)) + self.stats_layout.addRow("Max Heal:", QLabel(f"{item.max_heal} HP" if item.max_heal else "-")) + self.stats_layout.addRow("Min Heal:", QLabel(f"{item.min_heal} HP" if item.min_heal else "-")) + self.stats_layout.addRow("Decay:", QLabel(f"{item.decay:.2f} PEC/use" if item.decay else "-")) + self.stats_layout.addRow("Cost/Heal:", QLabel(f"{item.cost_per_heal:.4f} PED" if item.cost_per_heal else "-")) + self.stats_layout.addRow("Cost/Hour:", QLabel(f"{item.cost_per_hour:.2f} PED" if item.cost_per_hour else "-")) + if item.uses_per_minute: + self.stats_layout.addRow("Uses/Min:", QLabel(str(item.uses_per_minute))) def on_item_double_clicked(self, item, column): """Handle double click.""" @@ -325,6 +359,18 @@ class GearSelectorDialog(QDialog): self.gear_selected.emit("finder", f.name, { 'id': f.id, 'item_id': f.item_id, 'depth': float(f.depth), 'radius': float(f.radius), }) + elif self.gear_type == "medical_tool": + m = self.selected_gear + self.gear_selected.emit("medical_tool", m.name, { + 'id': m.id, + 'item_id': m.item_id, + 'max_heal': float(m.max_heal) if m.max_heal else 0, + 'min_heal': float(m.min_heal) if m.min_heal else 0, + 'decay': float(m.decay) if m.decay else 0, + 'cost_per_heal': float(m.cost_per_heal) if m.cost_per_heal else 0, + 'cost_per_hour': float(m.cost_per_hour) if m.cost_per_hour else 0, + 'uses_per_minute': m.uses_per_minute or 0, + }) self.accept() diff --git a/ui/loadout_manager.py b/ui/loadout_manager.py index aef7a68..24f44ed 100644 --- a/ui/loadout_manager.py +++ b/ui/loadout_manager.py @@ -857,6 +857,7 @@ class LoadoutManagerDialog(QDialog): """Main dialog for managing hunting loadouts with full API integration.""" loadout_saved = pyqtSignal(str) # Emitted when loadout is saved + loadout_selected = pyqtSignal(object) # Emitted when loadout is selected for use def __init__(self, parent=None, config_dir: Optional[str] = None): super().__init__(parent) @@ -1086,6 +1087,9 @@ class LoadoutManagerDialog(QDialog): # Buttons self.save_btn = QPushButton("💾 Save Loadout") self.save_btn.setObjectName("saveButton") + self.use_btn = QPushButton("✅ Use Loadout") + self.use_btn.setObjectName("useButton") + self.use_btn.setToolTip("Use this loadout for current session") self.load_btn = QPushButton("📂 Load Selected") self.delete_btn = QPushButton("🗑️ Delete") self.delete_btn.setObjectName("deleteButton") @@ -1112,6 +1116,7 @@ class LoadoutManagerDialog(QDialog): left_btn_layout = QHBoxLayout() left_btn_layout.addWidget(self.load_btn) + left_btn_layout.addWidget(self.use_btn) left_btn_layout.addWidget(self.delete_btn) left_layout.addLayout(left_btn_layout) @@ -1296,6 +1301,7 @@ class LoadoutManagerDialog(QDialog): # Buttons self.save_btn.clicked.connect(self._save_loadout) + self.use_btn.clicked.connect(self._use_selected) self.load_btn.clicked.connect(self._load_selected) self.delete_btn.clicked.connect(self._delete_selected) self.new_btn.clicked.connect(self._new_loadout) @@ -1686,6 +1692,37 @@ class LoadoutManagerDialog(QDialog): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load: {str(e)}") + def _use_selected(self): + """Use the selected loadout for the current session.""" + item = self.saved_list.currentItem() + if not item: + QMessageBox.information(self, "No Selection", "Please select a loadout to use") + return + + filepath = item.data(Qt.ItemDataRole.UserRole) + if not filepath: + return + + try: + with open(filepath, 'r') as f: + data = json.load(f) + config = LoadoutConfig.from_dict(data) + + self.current_loadout = config + # Emit signal with the loadout for main window to use + self.loadout_selected.emit(config) + + QMessageBox.information(self, "Loadout Selected", + f"Loadout '{config.name}' is now active for your session.\n\n" + f"Weapon: {config.weapon_name}\n" + f"Healing Tool: {config.heal_name}\n" + f"Total Cost: {config.calculate_total_cost_per_hour():.2f} PED/hr") + + self.accept() # Close dialog + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load loadout: {str(e)}") + def _delete_selected(self): """Delete the selected loadout.""" item = self.saved_list.currentItem() diff --git a/ui/main_window.py b/ui/main_window.py index 4494d4c..950b4de 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -562,6 +562,11 @@ class MainWindow(QMainWindow): 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) @@ -1345,8 +1350,25 @@ class MainWindow(QMainWindow): """Open Loadout Manager dialog.""" from ui.loadout_manager import LoadoutManagerDialog dialog = LoadoutManagerDialog(self) + dialog.loadout_selected.connect(self.on_loadout_selected) dialog.exec() + def on_loadout_selected(self, loadout): + """Handle loadout selection from Loadout Manager.""" + self._selected_loadout = loadout + self.log_info("Loadout", f"Selected loadout: {loadout.name}") + + # Update selected gear from loadout + if hasattr(loadout, 'weapon_name'): + self._selected_weapon = loadout.weapon_name + if hasattr(loadout, 'heal_cost_pec'): + # Create medical tool stats from loadout heal cost + self._selected_medical_tool = loadout.heal_name + self._selected_medical_tool_stats = { + 'decay': float(loadout.heal_cost_pec), + 'cost_per_heal': float(loadout.heal_cost_pec) / 100.0, # Convert PEC to PED + } + def on_select_gear(self, gear_type: str = "weapon"): """Open Gear Selector dialog.""" from ui.gear_selector import GearSelectorDialog @@ -1370,6 +1392,11 @@ class MainWindow(QMainWindow): 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."""