diff --git a/core/armor_system.py b/core/armor_system.py new file mode 100644 index 0000000..8f54f19 --- /dev/null +++ b/core/armor_system.py @@ -0,0 +1,1172 @@ +""" +Armor System for Lemontropia Suite + +Implements Entropia Universe armor mechanics: +- 7 armor slots: Head, Chest/Harness, Left Arm, Right Arm, Left Hand, Right Hand, Legs/Feet +- Full armor sets (e.g., "Ghost Set", "Shogun Set") with matching pieces +- Individual armor pieces (mix & match) +- 7 plate slots (one per armor piece) +- Plates take damage FIRST (shield layer) +- Plate protection + Armor protection = Total protection +- Plate decay tracked separately from armor decay +""" + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Optional, Dict, List, Tuple +from enum import Enum, auto + + +class ArmorSlot(Enum): + """Armor slot types in Entropia Universe.""" + HEAD = "head" + CHEST = "chest" # Also called Harness + LEFT_ARM = "left_arm" + RIGHT_ARM = "right_arm" + LEFT_HAND = "left_hand" + RIGHT_HAND = "right_hand" + LEGS = "legs" # Also called Thighs + Shins + Feet + + +# Full set of 7 slots +ALL_ARMOR_SLOTS = [ + ArmorSlot.HEAD, + ArmorSlot.CHEST, + ArmorSlot.LEFT_ARM, + ArmorSlot.RIGHT_ARM, + ArmorSlot.LEFT_HAND, + ArmorSlot.RIGHT_HAND, + ArmorSlot.LEGS, +] + + +@dataclass +class ProtectionProfile: + """Protection values for all damage types.""" + stab: Decimal = Decimal("0") + cut: Decimal = Decimal("0") + impact: Decimal = Decimal("0") + penetration: Decimal = Decimal("0") + shrapnel: Decimal = Decimal("0") + burn: Decimal = Decimal("0") + cold: Decimal = Decimal("0") + acid: Decimal = Decimal("0") + electric: Decimal = Decimal("0") + + def get_total(self) -> Decimal: + """Get total protection across all types.""" + return ( + self.stab + self.cut + self.impact + self.penetration + + self.shrapnel + self.burn + self.cold + self.acid + self.electric + ) + + def get_effective_against(self, damage_type: str) -> Decimal: + """Get protection value for a specific damage type.""" + return getattr(self, damage_type.lower(), Decimal("0")) + + def add(self, other: "ProtectionProfile") -> "ProtectionProfile": + """Add another protection profile to this one.""" + return ProtectionProfile( + stab=self.stab + other.stab, + cut=self.cut + other.cut, + impact=self.impact + other.impact, + penetration=self.penetration + other.penetration, + shrapnel=self.shrapnel + other.shrapnel, + burn=self.burn + other.burn, + cold=self.cold + other.cold, + acid=self.acid + other.acid, + electric=self.electric + other.electric, + ) + + def subtract(self, other: "ProtectionProfile") -> "ProtectionProfile": + """Subtract another protection profile from this one.""" + return ProtectionProfile( + stab=max(Decimal("0"), self.stab - other.stab), + cut=max(Decimal("0"), self.cut - other.cut), + impact=max(Decimal("0"), self.impact - other.impact), + penetration=max(Decimal("0"), self.penetration - other.penetration), + shrapnel=max(Decimal("0"), self.shrapnel - other.shrapnel), + burn=max(Decimal("0"), self.burn - other.burn), + cold=max(Decimal("0"), self.cold - other.cold), + acid=max(Decimal("0"), self.acid - other.acid), + electric=max(Decimal("0"), self.electric - other.electric), + ) + + def to_dict(self) -> Dict[str, str]: + """Convert to dictionary with string values.""" + return { + 'stab': str(self.stab), + 'cut': str(self.cut), + 'impact': str(self.impact), + 'penetration': str(self.penetration), + 'shrapnel': str(self.shrapnel), + 'burn': str(self.burn), + 'cold': str(self.cold), + 'acid': str(self.acid), + 'electric': str(self.electric), + } + + @classmethod + def from_dict(cls, data: Dict[str, str]) -> "ProtectionProfile": + """Create from dictionary.""" + return cls( + stab=Decimal(data.get('stab', '0')), + cut=Decimal(data.get('cut', '0')), + impact=Decimal(data.get('impact', '0')), + penetration=Decimal(data.get('penetration', '0')), + shrapnel=Decimal(data.get('shrapnel', '0')), + burn=Decimal(data.get('burn', '0')), + cold=Decimal(data.get('cold', '0')), + acid=Decimal(data.get('acid', '0')), + electric=Decimal(data.get('electric', '0')), + ) + + +@dataclass +class ArmorPlate: + """ + Armor plating that attaches to armor pieces. + Plates act as a shield layer - they take damage FIRST. + """ + name: str + item_id: str + decay_per_hit: Decimal # Decay in PEC per hit absorbed + protection: ProtectionProfile = field(default_factory=ProtectionProfile) + durability: int = 10000 # Plate durability (hits it can take) + + def get_total_protection(self) -> Decimal: + """Get total protection value.""" + return self.protection.get_total() + + def get_decay_for_hit(self, damage_absorbed: Decimal) -> Decimal: + """ + Calculate decay for absorbing damage. + In Entropia: Plate decay = base_decay + (damage_absorbed * decay_factor) + Simplified: Fixed decay per hit when plate absorbs damage. + """ + # Base decay plus a small amount per damage point + return self.decay_per_hit + (damage_absorbed * Decimal("0.001")) + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + 'name': self.name, + 'item_id': self.item_id, + 'decay_per_hit': str(self.decay_per_hit), + 'protection': self.protection.to_dict(), + 'durability': self.durability, + } + + @classmethod + def from_dict(cls, data: Dict) -> "ArmorPlate": + """Create from dictionary.""" + return cls( + name=data['name'], + item_id=data['item_id'], + decay_per_hit=Decimal(data['decay_per_hit']), + protection=ProtectionProfile.from_dict(data.get('protection', {})), + durability=data.get('durability', 10000), + ) + + +@dataclass +class ArmorPiece: + """ + Individual armor piece (e.g., Ghost Helmet, Shogun Harness). + Each piece protects one slot and can have one plate attached. + """ + name: str + item_id: str + slot: ArmorSlot + set_name: Optional[str] = None # e.g., "Ghost", "Shogun" + decay_per_hit: Decimal = Decimal("0.05") # Decay in PEC when hit + protection: ProtectionProfile = field(default_factory=ProtectionProfile) + durability: int = 10000 + weight: Decimal = Decimal("1.0") # Weight in kg + + # Optional plate attachment + attached_plate: Optional[ArmorPlate] = None + + def get_base_protection(self) -> ProtectionProfile: + """Get base protection without plate.""" + return self.protection + + def get_total_protection(self) -> ProtectionProfile: + """Get total protection including plate.""" + if self.attached_plate: + return self.protection.add(self.attached_plate.protection) + return self.protection + + def get_total_protection_value(self) -> Decimal: + """Get total protection value including plate.""" + return self.get_total_protection().get_total() + + def get_decay_for_hit(self, damage_after_plate: Decimal) -> Decimal: + """ + Calculate armor piece decay for taking damage. + Plate absorbs first, so damage_after_plate is what gets through. + """ + # Base decay plus small amount per damage point that hits armor + return self.decay_per_hit + (damage_after_plate * Decimal("0.001")) + + def attach_plate(self, plate: ArmorPlate) -> bool: + """Attach a plate to this armor piece.""" + self.attached_plate = plate + return True + + def remove_plate(self) -> Optional[ArmorPlate]: + """Remove and return the attached plate.""" + plate = self.attached_plate + self.attached_plate = None + return plate + + def get_slot_display_name(self) -> str: + """Get human-readable slot name.""" + slot_names = { + ArmorSlot.HEAD: "Head", + ArmorSlot.CHEST: "Chest/Harness", + ArmorSlot.LEFT_ARM: "Left Arm", + ArmorSlot.RIGHT_ARM: "Right Arm", + ArmorSlot.LEFT_HAND: "Left Hand", + ArmorSlot.RIGHT_HAND: "Right Hand", + ArmorSlot.LEGS: "Legs/Feet", + } + return slot_names.get(self.slot, self.slot.value) + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + 'name': self.name, + 'item_id': self.item_id, + 'slot': self.slot.value, + 'set_name': self.set_name, + 'decay_per_hit': str(self.decay_per_hit), + 'protection': self.protection.to_dict(), + 'durability': self.durability, + 'weight': str(self.weight), + 'attached_plate': self.attached_plate.to_dict() if self.attached_plate else None, + } + + @classmethod + def from_dict(cls, data: Dict) -> "ArmorPiece": + """Create from dictionary.""" + piece = cls( + name=data['name'], + item_id=data['item_id'], + slot=ArmorSlot(data['slot']), + set_name=data.get('set_name'), + decay_per_hit=Decimal(data['decay_per_hit']), + protection=ProtectionProfile.from_dict(data.get('protection', {})), + durability=data.get('durability', 10000), + weight=Decimal(data.get('weight', '1.0')), + ) + if data.get('attached_plate'): + piece.attached_plate = ArmorPlate.from_dict(data['attached_plate']) + return piece + + +@dataclass +class ArmorSet: + """ + Complete armor set (7 pieces covering all slots). + Examples: Ghost Set, Shogun Set, Vigilante Set + """ + name: str # e.g., "Ghost Set" + set_id: str + pieces: Dict[ArmorSlot, ArmorPiece] = field(default_factory=dict) + set_bonus: Optional[ProtectionProfile] = None # Some sets have bonuses + + def __post_init__(self): + """Ensure all pieces reference this set.""" + set_name = self.name.replace(" Set", "") + for slot, piece in self.pieces.items(): + piece.set_name = set_name + + def get_piece(self, slot: ArmorSlot) -> Optional[ArmorPiece]: + """Get armor piece for a specific slot.""" + return self.pieces.get(slot) + + def is_complete(self) -> bool: + """Check if set has all 7 pieces.""" + return len(self.pieces) == 7 and all(slot in self.pieces for slot in ALL_ARMOR_SLOTS) + + def get_total_protection(self) -> ProtectionProfile: + """Get total protection from all pieces including plates.""" + total = ProtectionProfile() + for piece in self.pieces.values(): + total = total.add(piece.get_total_protection()) + + # Add set bonus if complete + if self.set_bonus and self.is_complete(): + total = total.add(self.set_bonus) + + return total + + def get_total_decay_per_hit(self) -> Decimal: + """Get total decay per hit across all pieces (including plates).""" + total = Decimal("0") + for piece in self.pieces.values(): + total += piece.decay_per_hit + if piece.attached_plate: + total += piece.attached_plate.decay_per_hit + return total + + def get_pieces_list(self) -> List[ArmorPiece]: + """Get list of all pieces in slot order.""" + return [self.pieces.get(slot) for slot in ALL_ARMOR_SLOTS if slot in self.pieces] + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + 'name': self.name, + 'set_id': self.set_id, + 'pieces': {slot.value: piece.to_dict() for slot, piece in self.pieces.items()}, + 'set_bonus': self.set_bonus.to_dict() if self.set_bonus else None, + } + + @classmethod + def from_dict(cls, data: Dict) -> "ArmorSet": + """Create from dictionary.""" + pieces = { + ArmorSlot(slot): ArmorPiece.from_dict(piece_data) + for slot, piece_data in data.get('pieces', {}).items() + } + set_bonus = None + if data.get('set_bonus'): + set_bonus = ProtectionProfile.from_dict(data['set_bonus']) + return cls( + name=data['name'], + set_id=data['set_id'], + pieces=pieces, + set_bonus=set_bonus, + ) + + +@dataclass +class EquippedArmor: + """ + Currently equipped armor configuration. + Can mix pieces from different sets or wear a full set. + """ + # Individual pieces (mix & match) + pieces: Dict[ArmorSlot, ArmorPiece] = field(default_factory=dict) + + # Or reference a full set + full_set: Optional[ArmorSet] = None + + def get_piece(self, slot: ArmorSlot) -> Optional[ArmorPiece]: + """Get equipped piece for a slot.""" + if self.full_set: + return self.full_set.get_piece(slot) + return self.pieces.get(slot) + + def get_all_pieces(self) -> Dict[ArmorSlot, ArmorPiece]: + """Get all equipped pieces as a dict.""" + if self.full_set: + return self.full_set.pieces + return self.pieces + + def equip_piece(self, piece: ArmorPiece) -> None: + """Equip an individual armor piece.""" + # Unequip full set if equipping individual pieces + self.full_set = None + self.pieces[piece.slot] = piece + + def unequip_piece(self, slot: ArmorSlot) -> Optional[ArmorPiece]: + """Unequip a piece from a slot.""" + if self.full_set: + return None # Can't unequip individual pieces from full set + return self.pieces.pop(slot, None) + + def equip_full_set(self, armor_set: ArmorSet) -> None: + """Equip a full armor set.""" + self.full_set = armor_set + self.pieces = {} + + def unequip_full_set(self) -> Optional[ArmorSet]: + """Unequip full set.""" + set_ref = self.full_set + self.full_set = None + return set_ref + + def attach_plate_to_slot(self, slot: ArmorSlot, plate: ArmorPlate) -> bool: + """Attach a plate to the armor piece in a slot.""" + piece = self.get_piece(slot) + if piece: + piece.attach_plate(plate) + return True + return False + + def remove_plate_from_slot(self, slot: ArmorSlot) -> Optional[ArmorPlate]: + """Remove plate from armor piece in a slot.""" + piece = self.get_piece(slot) + if piece: + return piece.remove_plate() + return None + + def get_plate(self, slot: ArmorSlot) -> Optional[ArmorPlate]: + """Get attached plate for a slot.""" + piece = self.get_piece(slot) + return piece.attached_plate if piece else None + + def get_total_protection(self) -> ProtectionProfile: + """Get total protection from all equipped pieces and plates.""" + if self.full_set: + return self.full_set.get_total_protection() + + total = ProtectionProfile() + for piece in self.pieces.values(): + total = total.add(piece.get_total_protection()) + return total + + def get_total_decay_per_hit(self) -> Decimal: + """Get total decay per hit (armor + plates).""" + if self.full_set: + return self.full_set.get_total_decay_per_hit() + + total = Decimal("0") + for piece in self.pieces.values(): + total += piece.decay_per_hit + if piece.attached_plate: + total += piece.attached_plate.decay_per_hit + return total + + def get_coverage(self) -> Tuple[int, int]: + """Get armor coverage as (equipped_slots, total_slots).""" + pieces = self.get_all_pieces() + return (len(pieces), 7) + + def get_coverage_percentage(self) -> float: + """Get armor coverage as percentage.""" + equipped, total = self.get_coverage() + return (equipped / total) * 100 if total > 0 else 0 + + def get_slot_status(self) -> Dict[ArmorSlot, bool]: + """Get status of each slot (True if equipped).""" + pieces = self.get_all_pieces() + return {slot: slot in pieces for slot in ALL_ARMOR_SLOTS} + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + 'pieces': {slot.value: piece.to_dict() for slot, piece in self.pieces.items()}, + 'full_set': self.full_set.to_dict() if self.full_set else None, + } + + @classmethod + def from_dict(cls, data: Dict) -> "EquippedArmor": + """Create from dictionary.""" + equipped = cls() + + if data.get('full_set'): + equipped.full_set = ArmorSet.from_dict(data['full_set']) + else: + pieces = { + ArmorSlot(slot): ArmorPiece.from_dict(piece_data) + for slot, piece_data in data.get('pieces', {}).items() + } + equipped.pieces = pieces + + return equipped + + +# ============================================================================ +# Damage Absorption Logic +# ============================================================================ + +@dataclass +class HitResult: + """Result of a hit against armored target.""" + raw_damage: Decimal + damage_type: str + + # Damage absorbed + plate_absorbed: Decimal = Decimal("0") + armor_absorbed: Decimal = Decimal("0") + damage_to_avatar: Decimal = Decimal("0") + + # Decay incurred + plate_decay: Decimal = Decimal("0") + armor_decay: Decimal = Decimal("0") + total_decay: Decimal = Decimal("0") + + # Status + plate_broken: bool = False + armor_broken: bool = False + + +def calculate_hit_protection( + equipped_armor: EquippedArmor, + incoming_damage: Decimal, + damage_type: str, + hit_location: Optional[ArmorSlot] = None +) -> HitResult: + """ + Calculate damage absorption for a hit. + + In Entropia Universe: + 1. If hit_location specified, only that slot's protection applies + 2. Plates absorb damage FIRST (shield layer) + 3. Armor absorbs remaining damage + 4. Decay is calculated based on damage absorbed + + Args: + equipped_armor: Currently equipped armor + incoming_damage: Raw damage from attack + damage_type: Type of damage (impact, burn, etc.) + hit_location: Specific slot hit (None for full body/average) + + Returns: + HitResult with absorption details + """ + result = HitResult( + raw_damage=incoming_damage, + damage_type=damage_type, + ) + + # Get protection for the hit + if hit_location: + # Specific location hit + piece = equipped_armor.get_piece(hit_location) + if not piece: + # No armor on that slot - full damage + result.damage_to_avatar = incoming_damage + return result + + plate_prot = piece.attached_plate.protection.get_effective_against(damage_type) if piece.attached_plate else Decimal("0") + armor_prot = piece.protection.get_effective_against(damage_type) + + # Plate absorbs FIRST + plate_absorb = min(plate_prot, incoming_damage) + result.plate_absorbed = plate_absorb + remaining = incoming_damage - plate_absorb + + # Armor absorbs remainder + armor_absorb = min(armor_prot, remaining) + result.armor_absorbed = armor_absorb + result.damage_to_avatar = remaining - armor_absorb + + # Calculate decay + if piece.attached_plate and plate_absorb > 0: + result.plate_decay = piece.attached_plate.get_decay_for_hit(plate_absorb) + + if armor_absorb > 0: + result.armor_decay = piece.get_decay_for_hit(armor_absorb) + + result.total_decay = result.plate_decay + result.armor_decay + + else: + # Full body hit - use average protection from all equipped pieces + pieces = equipped_armor.get_all_pieces() + if not pieces: + result.damage_to_avatar = incoming_damage + return result + + # Calculate total protection across all slots + total_plate_prot = Decimal("0") + total_armor_prot = Decimal("0") + + for piece in pieces.values(): + total_armor_prot += piece.protection.get_effective_against(damage_type) + if piece.attached_plate: + total_plate_prot += piece.attached_plate.protection.get_effective_against(damage_type) + + # Plate absorbs FIRST + plate_absorb = min(total_plate_prot, incoming_damage) + result.plate_absorbed = plate_absorb + remaining = incoming_damage - plate_absorb + + # Armor absorbs remainder + armor_absorb = min(total_armor_prot, remaining) + result.armor_absorbed = armor_absorb + result.damage_to_avatar = remaining - armor_absorb + + # Distribute decay across all pieces that contributed + piece_count = len(pieces) + if piece_count > 0: + plate_decay_per = result.plate_decay / Decimal(piece_count) if plate_absorb > 0 else Decimal("0") + armor_decay_per = result.armor_decay / Decimal(piece_count) if armor_absorb > 0 else Decimal("0") + + # Sum actual decay from all pieces with plates/armor + total_plate_decay = Decimal("0") + total_armor_decay = Decimal("0") + + for piece in pieces.values(): + if piece.attached_plate and plate_absorb > 0: + total_plate_decay += piece.attached_plate.get_decay_for_hit(plate_absorb / piece_count) + if armor_absorb > 0: + total_armor_decay += piece.get_decay_for_hit(armor_absorb / piece_count) + + result.plate_decay = total_plate_decay + result.armor_decay = total_armor_decay + result.total_decay = total_plate_decay + total_armor_decay + + return result + + +# ============================================================================ +# Mock Data - Real Entropia Universe Armor Sets +# ============================================================================ + +def create_ghost_set() -> ArmorSet: + """Create the Ghost armor set (light, good vs cold/burn).""" + pieces = { + ArmorSlot.HEAD: ArmorPiece( + name="Ghost Helmet", + item_id="ghost_helmet", + slot=ArmorSlot.HEAD, + set_name="Ghost", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("2"), burn=Decimal("5"), cold=Decimal("5")), + weight=Decimal("0.3"), + ), + ArmorSlot.CHEST: ArmorPiece( + name="Ghost Harness", + item_id="ghost_harness", + slot=ArmorSlot.CHEST, + set_name="Ghost", + decay_per_hit=Decimal("0.035"), + protection=ProtectionProfile(impact=Decimal("4"), cut=Decimal("4"), stab=Decimal("4"), burn=Decimal("8"), cold=Decimal("8")), + weight=Decimal("0.7"), + ), + ArmorSlot.LEFT_ARM: ArmorPiece( + name="Ghost Arm Guards (L)", + item_id="ghost_arm_l", + slot=ArmorSlot.LEFT_ARM, + set_name="Ghost", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("2"), burn=Decimal("5"), cold=Decimal("5")), + weight=Decimal("0.3"), + ), + ArmorSlot.RIGHT_ARM: ArmorPiece( + name="Ghost Arm Guards (R)", + item_id="ghost_arm_r", + slot=ArmorSlot.RIGHT_ARM, + set_name="Ghost", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("2"), burn=Decimal("5"), cold=Decimal("5")), + weight=Decimal("0.3"), + ), + ArmorSlot.LEFT_HAND: ArmorPiece( + name="Ghost Gloves (L)", + item_id="ghost_gloves_l", + slot=ArmorSlot.LEFT_HAND, + set_name="Ghost", + decay_per_hit=Decimal("0.010"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1"), stab=Decimal("1"), burn=Decimal("3"), cold=Decimal("3")), + weight=Decimal("0.2"), + ), + ArmorSlot.RIGHT_HAND: ArmorPiece( + name="Ghost Gloves (R)", + item_id="ghost_gloves_r", + slot=ArmorSlot.RIGHT_HAND, + set_name="Ghost", + decay_per_hit=Decimal("0.010"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1"), stab=Decimal("1"), burn=Decimal("3"), cold=Decimal("3")), + weight=Decimal("0.2"), + ), + ArmorSlot.LEGS: ArmorPiece( + name="Ghost Thigh+Shin Guards", + item_id="ghost_legs", + slot=ArmorSlot.LEGS, + set_name="Ghost", + decay_per_hit=Decimal("0.030"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("3"), stab=Decimal("3"), burn=Decimal("7"), cold=Decimal("7")), + weight=Decimal("0.6"), + ), + } + return ArmorSet( + name="Ghost Set", + set_id="ghost_set", + pieces=pieces, + ) + + +def create_shogun_set() -> ArmorSet: + """Create the Shogun armor set (medium, good vs impact/cut).""" + pieces = { + ArmorSlot.HEAD: ArmorPiece( + name="Shogun Helmet", + item_id="shogun_helmet", + slot=ArmorSlot.HEAD, + set_name="Shogun", + decay_per_hit=Decimal("0.025"), + protection=ProtectionProfile(impact=Decimal("5"), cut=Decimal("4"), stab=Decimal("3"), burn=Decimal("2"), cold=Decimal("2")), + weight=Decimal("0.8"), + ), + ArmorSlot.CHEST: ArmorPiece( + name="Shogun Harness", + item_id="shogun_harness", + slot=ArmorSlot.CHEST, + set_name="Shogun", + decay_per_hit=Decimal("0.060"), + protection=ProtectionProfile(impact=Decimal("8"), cut=Decimal("6"), stab=Decimal("5"), burn=Decimal("4"), cold=Decimal("4")), + weight=Decimal("1.5"), + ), + ArmorSlot.LEFT_ARM: ArmorPiece( + name="Shogun Arm Guards (L)", + item_id="shogun_arm_l", + slot=ArmorSlot.LEFT_ARM, + set_name="Shogun", + decay_per_hit=Decimal("0.025"), + protection=ProtectionProfile(impact=Decimal("5"), cut=Decimal("4"), stab=Decimal("3"), burn=Decimal("2"), cold=Decimal("2")), + weight=Decimal("0.8"), + ), + ArmorSlot.RIGHT_ARM: ArmorPiece( + name="Shogun Arm Guards (R)", + item_id="shogun_arm_r", + slot=ArmorSlot.RIGHT_ARM, + set_name="Shogun", + decay_per_hit=Decimal("0.025"), + protection=ProtectionProfile(impact=Decimal("5"), cut=Decimal("4"), stab=Decimal("3"), burn=Decimal("2"), cold=Decimal("2")), + weight=Decimal("0.8"), + ), + ArmorSlot.LEFT_HAND: ArmorPiece( + name="Shogun Gloves (L)", + item_id="shogun_gloves_l", + slot=ArmorSlot.LEFT_HAND, + set_name="Shogun", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("2"), stab=Decimal("2"), burn=Decimal("1"), cold=Decimal("1")), + weight=Decimal("0.4"), + ), + ArmorSlot.RIGHT_HAND: ArmorPiece( + name="Shogun Gloves (R)", + item_id="shogun_gloves_r", + slot=ArmorSlot.RIGHT_HAND, + set_name="Shogun", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("2"), stab=Decimal("2"), burn=Decimal("1"), cold=Decimal("1")), + weight=Decimal("0.4"), + ), + ArmorSlot.LEGS: ArmorPiece( + name="Shogun Thigh+Shin Guards", + item_id="shogun_legs", + slot=ArmorSlot.LEGS, + set_name="Shogun", + decay_per_hit=Decimal("0.050"), + protection=ProtectionProfile(impact=Decimal("7"), cut=Decimal("5"), stab=Decimal("4"), burn=Decimal("3"), cold=Decimal("3")), + weight=Decimal("1.2"), + ), + } + return ArmorSet( + name="Shogun Set", + set_id="shogun_set", + pieces=pieces, + ) + + +def create_vigilante_set() -> ArmorSet: + """Create the Vigilante armor set (light, good all-around).""" + pieces = { + ArmorSlot.HEAD: ArmorPiece( + name="Vigilante Helmet", + item_id="vigilante_helmet", + slot=ArmorSlot.HEAD, + set_name="Vigilante", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("2"), stab=Decimal("2")), + weight=Decimal("0.4"), + ), + ArmorSlot.CHEST: ArmorPiece( + name="Vigilante Harness", + item_id="vigilante_harness", + slot=ArmorSlot.CHEST, + set_name="Vigilante", + decay_per_hit=Decimal("0.040"), + protection=ProtectionProfile(impact=Decimal("6"), cut=Decimal("5"), stab=Decimal("4")), + weight=Decimal("1.0"), + ), + ArmorSlot.LEFT_ARM: ArmorPiece( + name="Vigilante Arm Guards (L)", + item_id="vigilante_arm_l", + slot=ArmorSlot.LEFT_ARM, + set_name="Vigilante", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("2"), stab=Decimal("2")), + weight=Decimal("0.4"), + ), + ArmorSlot.RIGHT_ARM: ArmorPiece( + name="Vigilante Arm Guards (R)", + item_id="vigilante_arm_r", + slot=ArmorSlot.RIGHT_ARM, + set_name="Vigilante", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("2"), stab=Decimal("2")), + weight=Decimal("0.4"), + ), + ArmorSlot.LEFT_HAND: ArmorPiece( + name="Vigilante Gloves (L)", + item_id="vigilante_gloves_l", + slot=ArmorSlot.LEFT_HAND, + set_name="Vigilante", + decay_per_hit=Decimal("0.010"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("1"), stab=Decimal("1")), + weight=Decimal("0.2"), + ), + ArmorSlot.RIGHT_HAND: ArmorPiece( + name="Vigilante Gloves (R)", + item_id="vigilante_gloves_r", + slot=ArmorSlot.RIGHT_HAND, + set_name="Vigilante", + decay_per_hit=Decimal("0.010"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("1"), stab=Decimal("1")), + weight=Decimal("0.2"), + ), + ArmorSlot.LEGS: ArmorPiece( + name="Vigilante Thigh+Shin Guards", + item_id="vigilante_legs", + slot=ArmorSlot.LEGS, + set_name="Vigilante", + decay_per_hit=Decimal("0.030"), + protection=ProtectionProfile(impact=Decimal("5"), cut=Decimal("4"), stab=Decimal("3")), + weight=Decimal("0.8"), + ), + } + return ArmorSet( + name="Vigilante Set", + set_id="vigilante_set", + pieces=pieces, + ) + + +def create_hermes_set() -> ArmorSet: + """Create the Hermes armor set (medium, good vs penetration).""" + pieces = { + ArmorSlot.HEAD: ArmorPiece( + name="Hermes Helmet", + item_id="hermes_helmet", + slot=ArmorSlot.HEAD, + set_name="Hermes", + decay_per_hit=Decimal("0.020"), + protection=ProtectionProfile(impact=Decimal("3"), cut=Decimal("3"), stab=Decimal("2"), penetration=Decimal("3")), + weight=Decimal("0.5"), + ), + ArmorSlot.CHEST: ArmorPiece( + name="Hermes Harness", + item_id="hermes_harness", + slot=ArmorSlot.CHEST, + set_name="Hermes", + decay_per_hit=Decimal("0.050"), + protection=ProtectionProfile(impact=Decimal("10"), cut=Decimal("8"), stab=Decimal("7"), penetration=Decimal("5")), + weight=Decimal("1.2"), + ), + ArmorSlot.LEFT_ARM: ArmorPiece( + name="Hermes Arm Guards (L)", + item_id="hermes_arm_l", + slot=ArmorSlot.LEFT_ARM, + set_name="Hermes", + decay_per_hit=Decimal("0.020"), + protection=ProtectionProfile(impact=Decimal("4"), cut=Decimal("4"), stab=Decimal("3"), penetration=Decimal("2")), + weight=Decimal("0.5"), + ), + ArmorSlot.RIGHT_ARM: ArmorPiece( + name="Hermes Arm Guards (R)", + item_id="hermes_arm_r", + slot=ArmorSlot.RIGHT_ARM, + set_name="Hermes", + decay_per_hit=Decimal("0.020"), + protection=ProtectionProfile(impact=Decimal("4"), cut=Decimal("4"), stab=Decimal("3"), penetration=Decimal("2")), + weight=Decimal("0.5"), + ), + ArmorSlot.LEFT_HAND: ArmorPiece( + name="Hermes Gloves (L)", + item_id="hermes_gloves_l", + slot=ArmorSlot.LEFT_HAND, + set_name="Hermes", + decay_per_hit=Decimal("0.012"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("1"), penetration=Decimal("1")), + weight=Decimal("0.25"), + ), + ArmorSlot.RIGHT_HAND: ArmorPiece( + name="Hermes Gloves (R)", + item_id="hermes_gloves_r", + slot=ArmorSlot.RIGHT_HAND, + set_name="Hermes", + decay_per_hit=Decimal("0.012"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("1"), penetration=Decimal("1")), + weight=Decimal("0.25"), + ), + ArmorSlot.LEGS: ArmorPiece( + name="Hermes Thigh+Shin Guards", + item_id="hermes_legs", + slot=ArmorSlot.LEGS, + set_name="Hermes", + decay_per_hit=Decimal("0.040"), + protection=ProtectionProfile(impact=Decimal("7"), cut=Decimal("6"), stab=Decimal("5"), penetration=Decimal("4")), + weight=Decimal("1.0"), + ), + } + return ArmorSet( + name="Hermes Set", + set_id="hermes_set", + pieces=pieces, + ) + + +def create_pixie_set() -> ArmorSet: + """Create the Pixie armor set (light starter armor).""" + pieces = { + ArmorSlot.HEAD: ArmorPiece( + name="Pixie Helmet", + item_id="pixie_helmet", + slot=ArmorSlot.HEAD, + set_name="Pixie", + decay_per_hit=Decimal("0.008"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1"), stab=Decimal("1")), + weight=Decimal("0.2"), + ), + ArmorSlot.CHEST: ArmorPiece( + name="Pixie Harness", + item_id="pixie_harness", + slot=ArmorSlot.CHEST, + set_name="Pixie", + decay_per_hit=Decimal("0.020"), + protection=ProtectionProfile(impact=Decimal("4"), cut=Decimal("3"), stab=Decimal("2")), + weight=Decimal("0.5"), + ), + ArmorSlot.LEFT_ARM: ArmorPiece( + name="Pixie Arm Guards (L)", + item_id="pixie_arm_l", + slot=ArmorSlot.LEFT_ARM, + set_name="Pixie", + decay_per_hit=Decimal("0.008"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1"), stab=Decimal("1")), + weight=Decimal("0.2"), + ), + ArmorSlot.RIGHT_ARM: ArmorPiece( + name="Pixie Arm Guards (R)", + item_id="pixie_arm_r", + slot=ArmorSlot.RIGHT_ARM, + set_name="Pixie", + decay_per_hit=Decimal("0.008"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1"), stab=Decimal("1")), + weight=Decimal("0.2"), + ), + ArmorSlot.LEFT_HAND: ArmorPiece( + name="Pixie Gloves (L)", + item_id="pixie_gloves_l", + slot=ArmorSlot.LEFT_HAND, + set_name="Pixie", + decay_per_hit=Decimal("0.005"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1")), + weight=Decimal("0.1"), + ), + ArmorSlot.RIGHT_HAND: ArmorPiece( + name="Pixie Gloves (R)", + item_id="pixie_gloves_r", + slot=ArmorSlot.RIGHT_HAND, + set_name="Pixie", + decay_per_hit=Decimal("0.005"), + protection=ProtectionProfile(impact=Decimal("1"), cut=Decimal("1")), + weight=Decimal("0.1"), + ), + ArmorSlot.LEGS: ArmorPiece( + name="Pixie Thigh+Shin Guards", + item_id="pixie_legs", + slot=ArmorSlot.LEGS, + set_name="Pixie", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("1")), + weight=Decimal("0.4"), + ), + } + return ArmorSet( + name="Pixie Set", + set_id="pixie_set", + pieces=pieces, + ) + + +# ============================================================================ +# Mock Plates +# ============================================================================ + +def get_mock_plates() -> List[ArmorPlate]: + """Get list of available armor plates.""" + return [ + # General purpose plates + ArmorPlate( + name="Impact Plating I", + item_id="plate_impact_1", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(impact=Decimal("3")), + ), + ArmorPlate( + name="Impact Plating II", + item_id="plate_impact_2", + decay_per_hit=Decimal("0.030"), + protection=ProtectionProfile(impact=Decimal("6")), + ), + ArmorPlate( + name="Cut Plating I", + item_id="plate_cut_1", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(cut=Decimal("3")), + ), + ArmorPlate( + name="Stab Plating I", + item_id="plate_stab_1", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(stab=Decimal("3")), + ), + ArmorPlate( + name="Burn Plating I", + item_id="plate_burn_1", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(burn=Decimal("3")), + ), + ArmorPlate( + name="Cold Plating I", + item_id="plate_cold_1", + decay_per_hit=Decimal("0.015"), + protection=ProtectionProfile(cold=Decimal("3")), + ), + ArmorPlate( + name="Penetration Plating I", + item_id="plate_pen_1", + decay_per_hit=Decimal("0.020"), + protection=ProtectionProfile(penetration=Decimal("3")), + ), + # Composite plates (multi-type) + ArmorPlate( + name="Composite Plating I", + item_id="plate_composite_1", + decay_per_hit=Decimal("0.025"), + protection=ProtectionProfile( + impact=Decimal("2"), cut=Decimal("2"), stab=Decimal("2") + ), + ), + ArmorPlate( + name="Elemental Plating I", + item_id="plate_elemental_1", + decay_per_hit=Decimal("0.025"), + protection=ProtectionProfile( + burn=Decimal("2"), cold=Decimal("2"), acid=Decimal("2"), electric=Decimal("2") + ), + ), + # Heavy plates + ArmorPlate( + name="Heavy Impact Plating", + item_id="plate_heavy_impact", + decay_per_hit=Decimal("0.050"), + protection=ProtectionProfile(impact=Decimal("10")), + ), + ArmorPlate( + name="Heavy Universal Plating", + item_id="plate_heavy_uni", + decay_per_hit=Decimal("0.060"), + protection=ProtectionProfile( + impact=Decimal("3"), cut=Decimal("3"), stab=Decimal("3"), + burn=Decimal("3"), cold=Decimal("3"), penetration=Decimal("3") + ), + ), + ] + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def get_all_armor_sets() -> List[ArmorSet]: + """Get all available armor sets.""" + return [ + create_pixie_set(), + create_vigilante_set(), + create_ghost_set(), + create_shogun_set(), + create_hermes_set(), + ] + + +def get_armor_set_by_name(name: str) -> Optional[ArmorSet]: + """Get an armor set by name.""" + for armor_set in get_all_armor_sets(): + if armor_set.name.lower() == name.lower() or armor_set.set_id.lower() == name.lower(): + return armor_set + return None + + +def get_all_armor_pieces() -> List[ArmorPiece]: + """Get all individual armor pieces from all sets.""" + pieces = [] + for armor_set in get_all_armor_sets(): + pieces.extend(armor_set.pieces.values()) + return pieces + + +def get_pieces_by_slot(slot: ArmorSlot) -> List[ArmorPiece]: + """Get all armor pieces for a specific slot.""" + return [p for p in get_all_armor_pieces() if p.slot == slot] + + +def get_pieces_by_set(set_name: str) -> List[ArmorPiece]: + """Get all armor pieces from a specific set.""" + return [p for p in get_all_armor_pieces() if p.set_name and p.set_name.lower() == set_name.lower()] + + +def create_mixed_armor(selections: Dict[ArmorSlot, str]) -> EquippedArmor: + """ + Create mixed armor from piece names. + + Args: + selections: Dict mapping slots to piece names + + Returns: + EquippedArmor with mixed pieces + """ + equipped = EquippedArmor() + all_pieces = {p.name.lower(): p for p in get_all_armor_pieces()} + + for slot, piece_name in selections.items(): + piece_key = piece_name.lower() + if piece_key in all_pieces: + # Create a copy to avoid modifying original + original = all_pieces[piece_key] + piece_copy = ArmorPiece( + name=original.name, + item_id=original.item_id, + slot=original.slot, + set_name=original.set_name, + decay_per_hit=original.decay_per_hit, + protection=ProtectionProfile( + stab=original.protection.stab, + cut=original.protection.cut, + impact=original.protection.impact, + penetration=original.protection.penetration, + shrapnel=original.protection.shrapnel, + burn=original.protection.burn, + cold=original.protection.cold, + acid=original.protection.acid, + electric=original.protection.electric, + ), + durability=original.durability, + weight=original.weight, + ) + equipped.equip_piece(piece_copy) + + return equipped + + +def format_protection(profile: ProtectionProfile) -> str: + """Format protection profile for display.""" + parts = [] + if profile.impact > 0: + parts.append(f"Imp:{profile.impact}") + if profile.cut > 0: + parts.append(f"Cut:{profile.cut}") + if profile.stab > 0: + parts.append(f"Stab:{profile.stab}") + if profile.penetration > 0: + parts.append(f"Pen:{profile.penetration}") + if profile.shrapnel > 0: + parts.append(f"Shr:{profile.shrapnel}") + if profile.burn > 0: + parts.append(f"Burn:{profile.burn}") + if profile.cold > 0: + parts.append(f"Cold:{profile.cold}") + if profile.acid > 0: + parts.append(f"Acid:{profile.acid}") + if profile.electric > 0: + parts.append(f"Elec:{profile.electric}") + return ", ".join(parts) if parts else "None" diff --git a/core/log_watcher.py b/core/log_watcher.py index 967f932..c20db24 100644 --- a/core/log_watcher.py +++ b/core/log_watcher.py @@ -7,7 +7,7 @@ import re import os from pathlib import Path from typing import Callable, List, Dict, Any, Optional -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal import logging @@ -25,6 +25,79 @@ class LogEvent: data: Dict[str, Any] +@dataclass +class LootItem: + """Represents a single loot item.""" + name: str + quantity: int + value_ped: Decimal + is_shrapnel: bool = False + is_universal_ammo: bool = False + + +@dataclass +class HuntingSessionStats: + """Statistics for a hunting session tracked from chat.log.""" + # Financial tracking + 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') # Non-shrapnel, non-UA loot + + # Cost tracking + 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') + 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 + kills: int = 0 + + # Special events + globals_count: int = 0 + hofs_count: int = 0 + personal_globals: List[Dict[str, Any]] = field(default_factory=list) + + # Calculated metrics + @property + def net_profit_ped(self) -> Decimal: + """Calculate net profit (excluding shrapnel from loot).""" + return self.total_other_loot_ped - self.total_cost_ped + + @property + def return_percentage(self) -> Decimal: + """Calculate return percentage (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 cost_per_kill(self) -> Decimal: + """Calculate cost per kill.""" + if self.kills > 0: + return self.total_cost_ped / self.kills + return Decimal('0.0') + + @property + def dpp(self) -> Decimal: + """Calculate 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: + """Calculate average damage per kill.""" + if self.kills > 0: + return self.damage_dealt / self.kills + return Decimal('0.0') + + class LogWatcher: """ Watches Entropia Universe chat.log and notifies observers of events. @@ -55,6 +128,35 @@ class LogWatcher: r'Värde:\s+(\d+(?:\.\d+)?)\s+PED', re.IGNORECASE ) + + # LOOT PATTERN WITHOUT VALUE (some items don't show value) + # English: "You received Animal Thyroid Oil x 5" + PATTERN_LOOT_NO_VALUE_EN = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]:?\s*\[?\]?\s*' + r'You\s+received\s+\[?([\w\s\-()]+?)\]?\s+x\s*(\d+)', + re.IGNORECASE + ) + + PATTERN_LOOT_NO_VALUE_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'Du\s+fick\s+([\w\s\-()]+?)\s+x\s*(\d+)', + re.IGNORECASE + ) + + # KILL PATTERNS - "You killed" messages (for accurate kill counting) + # English: "You killed [Creature Name]" + # Swedish: "Du dödade [Creature Name]" + PATTERN_KILL_EN = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]:?\s*\[?\]?\s*' + r'You\s+killed\s+\[?([\w\s\-()]+?)\]?', + re.IGNORECASE + ) + + PATTERN_KILL_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'Du\s+dödade\s+\[?([\w\s\-()]+?)\]?', + re.IGNORECASE + ) # GLOBAL PATTERNS (Other players) # English: "PlayerName globals in Zone for 150.00 PED" @@ -92,6 +194,14 @@ class LogWatcher: # HALL OF FAME PATTERNS # Swedish: "...En post har lagts till i Hall of Fame!" + # English: "[Hall of Fame] Player killed a creature (Creature) for X PED" + PATTERN_HOF_EN = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[Hall\s+of\s+Fame\]\s+\[?\]?\s*' + r'([\w\s]+?)\s+killed\s+a\s+creature\s+\(([^)]+)\)\s+' + r'(?:for|with\s+a\s+value\s+of)\s+(\d+(?:\.\d+)?)\s+PED', + re.IGNORECASE + ) + PATTERN_HOF_MARKER = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[\w+\]\s+\[?\]?\s*' r'.*?Hall\s+of\s+Fame', @@ -122,6 +232,12 @@ class LogWatcher: r'You\s+have\s+advanced\s+to\s+level\s+(\d+)\s+in\s+([\w\s]+)', re.IGNORECASE ) + + PATTERN_LEVEL_UP_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'Du\s+har\s+avancerat\s+till\s+nivå\s+(\d+)\s+i\s+([\w\s]+)', + re.IGNORECASE + ) # DAMAGE DEALT - Swedish & English # Swedish: "Du orsakade 13.5 poäng skada" @@ -173,7 +289,7 @@ class LogWatcher: # English: "You healed yourself 25.5 points" PATTERN_HEAL_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' - r'Du\s+läkte\s+dig\s+jälv\s+(\d+(?:\.\d+)?)\s+poäng', + r'Du\s+läkte\s+dig\själv\s+(\d+(?:\.\d+)?)\s+poäng', re.IGNORECASE ) @@ -188,7 +304,7 @@ class LogWatcher: # English: "Your Piron PBP-17 (L) has reached tier 2.68" PATTERN_WEAPON_TIER_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' - r'Din\s+([\w\s\-()]+?)\s+har\s+nått\s+nivå\s+(\d+(?:\.\d+)?)', + r'Din\s+([\w\s\-()]+?)\s+har\snått\s+nivå\s+(\d+(?:\.\d+)?)', re.IGNORECASE ) @@ -200,24 +316,43 @@ class LogWatcher: # COMBAT EVADE/DODGE/MISS # English: "You Evaded", "The target Evaded your attack", "The attack missed you" - PATTERN_EVADE = re.compile( + # Swedish: "Du undvek", "Målet undvek din attack" + PATTERN_EVADE_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'(You\s+Evaded|You\s+dodged|The\s+target\s+Evaded\s+your\s+attack|The\s+target\s+Dodged|The\s+attack\s+missed\s+you)', re.IGNORECASE ) + + PATTERN_EVADE_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'(Du\s+undvek|Målet\s+undvek\s+din\s+attack|Attacken\s+missade\s+dig)', + re.IGNORECASE + ) # DECAY (when weapon durability decreases) # English: "Your Omegaton M2100 has decayed 15 PEC" # Swedish: "Din Piron PBP-17 (L) har nått minimalt skick" - PATTERN_DECAY = re.compile( + PATTERN_DECAY_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' - r'(?:Your|Din)\s+([\w\s\-()]+?)\s+(?:has\s+decayed|har\s+nått\s+minimalt\s+skick)', + r'Your\s+([\w\s\-()]+?)\s+has\s+decayed\s+(\d+(?:\.\d+)?)\s+PEC', + re.IGNORECASE + ) + + PATTERN_DECAY_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'Din\s+([\w\s\-()]+?)\s+har\s+decayed\s+(\d+(?:\.\d+)?)\s+PEC', + re.IGNORECASE + ) + + PATTERN_WEAPON_BROKEN_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'Din\s+([\w\s\-()]+?)\s+har\s+nått\s+minimalt\s+skick', re.IGNORECASE ) # BROKEN ENHANCERS # English: "Your enhancer Weapon Damage Enhancer 1 on your Piron PBP-17 (L) broke" - PATTERN_ENHANCER_BROKEN = re.compile( + PATTERN_ENHANCER_BROKEN_EN = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' r'Your\s+enhancer\s+([\w\s]+?)\s+on\s+your\s+([\w\s\-()]+?)\s+broke', re.IGNORECASE @@ -225,11 +360,17 @@ class LogWatcher: # PED TRANSFER # Swedish: "Överföring slutförd! 3.38000 PED har överförts till ditt PED-kort." - PATTERN_PED_TRANSFER = re.compile( + PATTERN_PED_TRANSFER_SV = re.compile( r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' r'Överföring\s+slutförd.*?((\d+(?:\.\d+)?))\s+PED', re.IGNORECASE ) + + PATTERN_PED_TRANSFER_EN = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+' + r'Transfer\s+complete.*?((\d+(?:\.\d+)?))\s+PED', + re.IGNORECASE + ) # ATTRIBUTE GAIN (Agility, etc) # Swedish: "Din Agility har förbättrats med 0.0001" @@ -246,14 +387,33 @@ class LogWatcher: re.IGNORECASE ) + # TEAM HUNT PATTERNS + # "You received 0.1234 PED from your teammates' activity." + PATTERN_TEAM_SHARE_EN = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'You\s+received\s+(\d+(?:\.\d+)?)\s+PED\s+from\s+your\s+teammates', + re.IGNORECASE + ) + + PATTERN_TEAM_SHARE_SV = re.compile( + r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[System\]\s+\[?\]?\s*' + r'Du\s+fick\s+(\d+(?:\.\d+)?)\s+PED\s+från\s+dina\s+lagkamrater', + re.IGNORECASE + ) + EVENT_PATTERNS = { 'loot_en': PATTERN_LOOT_EN, 'loot_sv': PATTERN_LOOT_SV, + 'loot_no_value_en': PATTERN_LOOT_NO_VALUE_EN, + 'loot_no_value_sv': PATTERN_LOOT_NO_VALUE_SV, + 'kill_en': PATTERN_KILL_EN, + 'kill_sv': PATTERN_KILL_SV, 'global_en': PATTERN_GLOBAL_EN, 'global_sv': PATTERN_GLOBAL_SV, 'personal_global_en': PATTERN_PERSONAL_GLOBAL_EN, 'personal_global_sv': PATTERN_PERSONAL_GLOBAL_SV, - 'hof': PATTERN_HOF_MARKER, + 'hof_en': PATTERN_HOF_EN, + 'hof_marker': PATTERN_HOF_MARKER, 'skill_en': PATTERN_SKILL_EN, 'skill_sv': PATTERN_SKILL_SV, 'damage_dealt_sv': PATTERN_DAMAGE_DEALT_SV, @@ -266,12 +426,18 @@ class LogWatcher: 'heal_en': PATTERN_HEAL_EN, 'weapon_tier_sv': PATTERN_WEAPON_TIER_SV, 'weapon_tier_en': PATTERN_WEAPON_TIER_EN, - 'evade': PATTERN_EVADE, - 'decay': PATTERN_DECAY, - 'enhancer_broken': PATTERN_ENHANCER_BROKEN, - 'ped_transfer': PATTERN_PED_TRANSFER, + 'evade_en': PATTERN_EVADE_EN, + 'evade_sv': PATTERN_EVADE_SV, + 'decay_en': PATTERN_DECAY_EN, + 'decay_sv': PATTERN_DECAY_SV, + 'weapon_broken_sv': PATTERN_WEAPON_BROKEN_SV, + 'enhancer_broken_en': PATTERN_ENHANCER_BROKEN_EN, + 'ped_transfer_sv': PATTERN_PED_TRANSFER_SV, + 'ped_transfer_en': PATTERN_PED_TRANSFER_EN, 'attribute_sv': PATTERN_ATTRIBUTE_SV, 'attribute_en': PATTERN_ATTRIBUTE_EN, + 'team_share_en': PATTERN_TEAM_SHARE_EN, + 'team_share_sv': PATTERN_TEAM_SHARE_SV, } def __init__(self, log_path: Optional[str] = None, @@ -295,7 +461,7 @@ class LogWatcher: 'damage_dealt': [], 'damage_taken': [], 'heal': [], 'weapon_tier': [], 'evade': [], 'decay': [], 'critical_hit': [], 'ped_transfer': [], 'attribute': [], - 'any': [], + 'kill': [], 'team_share': [], 'any': [], } self._running = False @@ -360,6 +526,28 @@ class LogWatcher: """Parse EU timestamp format.""" return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") + def _is_shrapnel(self, item_name: str) -> bool: + """Check if item is Shrapnel.""" + return item_name.strip().lower() == 'shrapnel' + + def _is_universal_ammo(self, item_name: str) -> bool: + """Check if item is Universal Ammo.""" + name = item_name.strip().lower() + return name == 'universal ammo' or name == 'universell ammunition' + + def _categorize_loot(self, item_name: str, value_ped: Decimal) -> LootItem: + """Categorize loot item and return LootItem.""" + is_shrapnel = self._is_shrapnel(item_name) + is_ua = self._is_universal_ammo(item_name) + + return LootItem( + name=item_name.strip(), + quantity=1, # Will be set by caller + value_ped=value_ped, + is_shrapnel=is_shrapnel, + is_universal_ammo=is_ua + ) + def _parse_line(self, line: str) -> Optional[LogEvent]: """ Parse a single log line. @@ -369,7 +557,18 @@ class LogWatcher: if not line: return None - # Try each pattern + # Try each pattern in priority order + + # KILL - Swedish + match = self.PATTERN_KILL_SV.match(line) + if match: + return self._create_kill_event(match, line, 'swedish') + + # KILL - English + match = self.PATTERN_KILL_EN.match(line) + if match: + return self._create_kill_event(match, line, 'english') + # LOOT - Swedish (prioritize based on your game client) match = self.PATTERN_LOOT_SV.match(line) if match: @@ -379,6 +578,16 @@ class LogWatcher: match = self.PATTERN_LOOT_EN.match(line) if match: return self._create_loot_event_en(match, line) + + # LOOT WITHOUT VALUE - Swedish + match = self.PATTERN_LOOT_NO_VALUE_SV.match(line) + if match: + return self._create_loot_event_no_value(match, line, 'swedish') + + # LOOT WITHOUT VALUE - English + match = self.PATTERN_LOOT_NO_VALUE_EN.match(line) + if match: + return self._create_loot_event_no_value(match, line, 'english') # GLOBAL - Swedish match = self.PATTERN_GLOBAL_SV.match(line) @@ -399,8 +608,13 @@ class LogWatcher: match = self.PATTERN_PERSONAL_GLOBAL_EN.match(line) if match: return self._create_personal_global_event(match, line, 'english') + + # HOF - English + match = self.PATTERN_HOF_EN.match(line) + if match: + return self._create_hof_event(match, line, 'english') - # HOF + # HOF Marker match = self.PATTERN_HOF_MARKER.match(line) if match: return LogEvent( @@ -451,6 +665,20 @@ class LogWatcher: 'language': 'english' } ) + + # LEVEL UP - Swedish + match = self.PATTERN_LEVEL_UP_SV.match(line) + if match: + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='level_up', + raw_line=line, + data={ + 'new_level': int(match.group(2)), + 'skill_name': match.group(3).strip(), + 'language': 'swedish' + } + ) # DAMAGE DEALT - Swedish match = self.PATTERN_DAMAGE_DEALT_SV.match(line) @@ -560,28 +788,56 @@ class LogWatcher: } ) - # EVADE/DODGE/MISS - match = self.PATTERN_EVADE.match(line) + # EVADE/DODGE/MISS - English + match = self.PATTERN_EVADE_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='evade', raw_line=line, - data={'type': match.group(2)} + data={'type': match.group(2), 'language': 'english'} + ) + + # EVADE/DODGE/MISS - Swedish + match = self.PATTERN_EVADE_SV.match(line) + if match: + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='evade', + raw_line=line, + data={'type': match.group(2), 'language': 'swedish'} ) - # DECAY - match = self.PATTERN_DECAY.match(line) + # DECAY - English + match = self.PATTERN_DECAY_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='decay', raw_line=line, - data={'item': match.group(2).strip()} + data={ + 'item': match.group(2).strip(), + 'amount_pec': Decimal(match.group(3)), + 'language': 'english' + } + ) + + # DECAY - Swedish + match = self.PATTERN_DECAY_SV.match(line) + if match: + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='decay', + raw_line=line, + data={ + 'item': match.group(2).strip(), + 'amount_pec': Decimal(match.group(3)), + 'language': 'swedish' + } ) - # BROKEN ENHANCER - match = self.PATTERN_ENHANCER_BROKEN.match(line) + # BROKEN ENHANCER - English + match = self.PATTERN_ENHANCER_BROKEN_EN.match(line) if match: return LogEvent( timestamp=self._parse_timestamp(match.group(1)), @@ -589,7 +845,8 @@ class LogWatcher: raw_line=line, data={ 'enhancer_type': match.group(2).strip(), - 'weapon': match.group(3).strip() + 'weapon': match.group(3).strip(), + 'language': 'english' } ) @@ -620,37 +877,114 @@ class LogWatcher: 'language': 'english' } ) + + # TEAM SHARE - English + match = self.PATTERN_TEAM_SHARE_EN.match(line) + if match: + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='team_share', + raw_line=line, + data={ + 'amount_ped': Decimal(match.group(2)), + 'language': 'english' + } + ) + + # TEAM SHARE - Swedish + match = self.PATTERN_TEAM_SHARE_SV.match(line) + if match: + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='team_share', + raw_line=line, + data={ + 'amount_ped': Decimal(match.group(2)), + 'language': 'swedish' + } + ) return None def _create_loot_event_sv(self, match: re.Match, line: str) -> LogEvent: """Create loot event from Swedish pattern.""" + item_name = match.group(2).strip() + quantity = int(match.group(3)) + value_ped = Decimal(match.group(4)) + + loot_item = self._categorize_loot(item_name, value_ped) + loot_item.quantity = quantity + return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='loot', raw_line=line, data={ - 'item_name': match.group(2).strip(), - 'quantity': int(match.group(3)), - 'value_ped': Decimal(match.group(4)), + 'item_name': loot_item.name, + 'quantity': loot_item.quantity, + 'value_ped': loot_item.value_ped, + 'is_shrapnel': loot_item.is_shrapnel, + 'is_universal_ammo': loot_item.is_universal_ammo, 'language': 'swedish' } ) def _create_loot_event_en(self, match: re.Match, line: str) -> LogEvent: """Create loot event from English pattern.""" - value = match.group(4) + item_name = match.group(2).strip() + quantity = int(match.group(3)) + value_ped = Decimal(match.group(4)) if match.group(4) else Decimal('0') + + loot_item = self._categorize_loot(item_name, value_ped) + loot_item.quantity = quantity + return LogEvent( timestamp=self._parse_timestamp(match.group(1)), event_type='loot', raw_line=line, data={ - 'item_name': match.group(2).strip(), - 'quantity': int(match.group(3)), - 'value_ped': Decimal(value) if value else Decimal('0'), + 'item_name': loot_item.name, + 'quantity': loot_item.quantity, + 'value_ped': loot_item.value_ped, + 'is_shrapnel': loot_item.is_shrapnel, + 'is_universal_ammo': loot_item.is_universal_ammo, 'language': 'english' } ) + + def _create_loot_event_no_value(self, match: re.Match, line: str, language: str) -> LogEvent: + """Create loot event without value.""" + item_name = match.group(2).strip() + quantity = int(match.group(3)) + + loot_item = self._categorize_loot(item_name, Decimal('0')) + loot_item.quantity = quantity + + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='loot', + raw_line=line, + data={ + 'item_name': loot_item.name, + 'quantity': loot_item.quantity, + 'value_ped': loot_item.value_ped, + 'is_shrapnel': loot_item.is_shrapnel, + 'is_universal_ammo': loot_item.is_universal_ammo, + 'language': language + } + ) + + def _create_kill_event(self, match: re.Match, line: str, language: str) -> LogEvent: + """Create kill event.""" + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='kill', + raw_line=line, + data={ + 'creature_name': match.group(2).strip(), + 'language': language + } + ) def _create_global_event_sv(self, match: re.Match, line: str) -> LogEvent: """Create global event from Swedish pattern.""" @@ -693,6 +1027,20 @@ class LogWatcher: 'language': language } ) + + def _create_hof_event(self, match: re.Match, line: str, language: str) -> LogEvent: + """Create Hall of Fame event.""" + return LogEvent( + timestamp=self._parse_timestamp(match.group(1)), + event_type='hof', + raw_line=line, + data={ + 'player_name': match.group(2).strip(), + 'creature': match.group(3).strip(), + 'value_ped': Decimal(match.group(4)), + 'language': language + } + ) # ======================================================================== # ASYNC POLLING LOOP @@ -763,6 +1111,219 @@ class LogWatcher: self._last_file_size = current_size +# ============================================================================ +# HUNTING SESSION TRACKER +# ============================================================================ + +class HuntingSessionTracker: + """ + Tracks hunting session statistics from LogWatcher events. + + This class accumulates all hunting-related data and provides + real-time metrics like profit/loss, return percentage, etc. + """ + + def __init__(self): + self.stats = HuntingSessionStats() + self._session_start: Optional[datetime] = None + self._session_active = False + + # Callbacks for real-time updates + self._on_stats_update: Optional[Callable] = None + + def start_session(self): + """Start a new hunting session.""" + self._session_start = datetime.now() + self._session_active = True + self.stats = HuntingSessionStats() + logger.info("Hunting session started") + + def end_session(self) -> HuntingSessionStats: + """End the current hunting session and return final stats.""" + self._session_active = False + logger.info("Hunting session ended") + return self.stats + + def is_active(self) -> bool: + """Check if session is active.""" + return self._session_active + + def set_stats_callback(self, callback: Callable[[HuntingSessionStats], None]): + """Set callback for real-time stats updates.""" + self._on_stats_update = callback + + def _notify_update(self): + """Notify listeners of stats update.""" + if self._on_stats_update: + try: + self._on_stats_update(self.stats) + except Exception as e: + logger.error(f"Stats callback error: {e}") + + def on_loot(self, event: LogEvent): + """Process loot event.""" + if not self._session_active: + return + + data = event.data + value_ped = data.get('value_ped', Decimal('0.0')) + is_shrapnel = data.get('is_shrapnel', False) + is_ua = data.get('is_universal_ammo', False) + + self.stats.total_loot_ped += value_ped + + if is_shrapnel: + self.stats.total_shrapnel_ped += value_ped + elif is_ua: + self.stats.total_universal_ammo_ped += value_ped + else: + self.stats.total_other_loot_ped += value_ped + + self._notify_update() + + def on_kill(self, event: LogEvent): + """Process kill event.""" + if not self._session_active: + return + + self.stats.kills += 1 + self._notify_update() + + def on_damage_dealt(self, event: LogEvent): + """Process damage dealt event.""" + if not self._session_active: + return + + damage = event.data.get('damage', Decimal('0.0')) + self.stats.damage_dealt += damage + self.stats.shots_fired += 1 # Each damage event = 1 shot + self._notify_update() + + def on_damage_taken(self, event: LogEvent): + """Process damage taken event.""" + if not self._session_active: + return + + damage = event.data.get('damage', Decimal('0.0')) + self.stats.damage_taken += damage + self._notify_update() + + def on_heal(self, event: LogEvent): + """Process heal event.""" + if not self._session_active: + return + + heal_amount = event.data.get('heal_amount', Decimal('0.0')) + self.stats.healing_done += heal_amount + self._notify_update() + + def on_global(self, event: LogEvent): + """Process global event.""" + if not self._session_active: + return + + self.stats.globals_count += 1 + + # Store personal global details + if event.event_type == 'personal_global': + self.stats.personal_globals.append({ + 'timestamp': event.timestamp, + 'creature': event.data.get('creature', 'Unknown'), + 'value_ped': event.data.get('value_ped', Decimal('0.0')) + }) + + self._notify_update() + + def on_hof(self, event: LogEvent): + """Process Hall of Fame event.""" + if not self._session_active: + return + + self.stats.hofs_count += 1 + + # Store HoF details + if 'creature' in event.data: + self.stats.personal_globals.append({ + 'timestamp': event.timestamp, + 'creature': event.data.get('creature', 'Unknown'), + 'value_ped': event.data.get('value_ped', Decimal('0.0')), + 'is_hof': True + }) + + self._notify_update() + + def on_decay(self, event: LogEvent): + """Process decay event.""" + if not self._session_active: + return + + # Convert PEC to PED + amount_pec = event.data.get('amount_pec', Decimal('0.0')) + amount_ped = amount_pec / Decimal('100') + + self.stats.weapon_cost_ped += amount_ped + self.stats.total_cost_ped += amount_ped + self._notify_update() + + def add_weapon_cost(self, cost_ped: Decimal): + """Manually add weapon cost (for calculated decay).""" + if not self._session_active: + return + + self.stats.weapon_cost_ped += cost_ped + self.stats.total_cost_ped += cost_ped + self._notify_update() + + def add_armor_cost(self, cost_ped: Decimal): + """Manually add armor cost.""" + if not self._session_active: + return + + self.stats.armor_cost_ped += cost_ped + self.stats.total_cost_ped += cost_ped + self._notify_update() + + def add_healing_cost(self, cost_ped: Decimal): + """Manually add healing cost.""" + if not self._session_active: + return + + self.stats.healing_cost_ped += cost_ped + self.stats.total_cost_ped += cost_ped + self._notify_update() + + def get_stats(self) -> HuntingSessionStats: + """Get current stats.""" + return self.stats + + def get_summary(self) -> Dict[str, Any]: + """Get session summary as dictionary.""" + return { + 'session_active': self._session_active, + 'session_start': self._session_start.isoformat() if self._session_start else None, + 'total_loot_ped': float(self.stats.total_loot_ped), + 'total_shrapnel_ped': float(self.stats.total_shrapnel_ped), + 'total_universal_ammo_ped': float(self.stats.total_universal_ammo_ped), + 'total_other_loot_ped': float(self.stats.total_other_loot_ped), + 'total_cost_ped': float(self.stats.total_cost_ped), + 'weapon_cost_ped': float(self.stats.weapon_cost_ped), + 'armor_cost_ped': float(self.stats.armor_cost_ped), + 'healing_cost_ped': float(self.stats.healing_cost_ped), + 'net_profit_ped': float(self.stats.net_profit_ped), + 'return_percentage': float(self.stats.return_percentage), + 'cost_per_kill': float(self.stats.cost_per_kill), + 'dpp': float(self.stats.dpp), + 'damage_dealt': float(self.stats.damage_dealt), + 'damage_taken': float(self.stats.damage_taken), + 'healing_done': float(self.stats.healing_done), + 'shots_fired': self.stats.shots_fired, + 'kills': self.stats.kills, + 'globals_count': self.stats.globals_count, + 'hofs_count': self.stats.hofs_count, + 'damage_per_kill': float(self.stats.damage_per_kill), + } + + # ============================================================================ # MOCK MODE SUPPORT # ============================================================================ @@ -774,14 +1335,22 @@ class MockLogGenerator: "2026-02-08 14:23:15 [System] You received Shrapnel x 123 (Value: 1.23 PED)", "2026-02-08 14:23:45 [System] You gained 0.45 experience in your Rifle skill", "2026-02-08 14:24:02 [System] Your Omegaton M2100 has decayed 15 PEC", - "2026-02-08 14:25:30 [System] PlayerOne globals in Twin Peaks for 150.00 PED", - "2026-02-08 14:26:10 [System] You received Animal Thyroid Oil x 5", + "2026-02-08 14:25:30 [Globals] PlayerOne globals in Twin Peaks for 150.00 PED", + "2026-02-08 14:26:10 [System] You received Animal Thyroid Oil x 5 (Value: 2.50 PED)", + "2026-02-08 14:26:15 [System] You killed Araneatrox Young", "2026-02-08 14:27:55 [System] Congratulations! You have advanced to level 45 in Rifle", - "2026-02-08 14:30:00 [System] PlayerTwo is in the Hall of Fame! Loot of 2500.00 PED", + "2026-02-08 14:28:30 [Globals] You killed a creature (Cornundacauda) with a value of 75.00 PED", + "2026-02-08 14:30:00 [Hall of Fame] PlayerTwo killed a creature (Atrox) for 2500.00 PED", + "2026-02-08 14:31:15 [System] You received Universal Ammo x 50 (Value: 0.50 PED)", + "2026-02-08 14:32:20 [System] You inflicted 45.5 points of damage", + "2026-02-08 14:32:25 [System] You took 12.3 points of damage", + "2026-02-08 14:33:00 [System] You healed yourself 25.0 points", + "2026-02-08 14:34:10 [System] Critical hit - Additional damage! You inflicted 89.2 points of damage", # Swedish examples "2025-09-23 19:36:43 [System] Du fick Shrapnel x (4627) Värde: 0.4627 PED", "2025-09-23 19:36:08 [System] Du har fått 0.3238 erfarenhet i din Translocation färdighet", "2025-09-23 19:36:18 [System] Du orsakade 13.5 poäng skada", + "2025-09-23 19:37:00 [System] Du dödade Araneatrox Young", ] @classmethod @@ -804,5 +1373,8 @@ class MockLogGenerator: __all__ = [ 'LogWatcher', 'LogEvent', + 'LootItem', + 'HuntingSessionStats', + 'HuntingSessionTracker', 'MockLogGenerator' ] diff --git a/docs/ARMOR_GUIDE_2020.md b/docs/ARMOR_GUIDE_2020.md new file mode 100644 index 0000000..e6afd51 --- /dev/null +++ b/docs/ARMOR_GUIDE_2020.md @@ -0,0 +1,176 @@ +# Entropia Universe Armor Mechanics - Official Guide + +**Source:** "A Most Complete Guide to Armors (2020 Edition)" - PlanetCalypsoForum +**Thread:** https://www.planetcalypsoforum.com/forum/index.php?threads/a-most-complete-guide-to-armors-2020-edition.270884/ +**Date:** 2026-02-09 + +--- + +## Loot 2.0 Armor Changes (VU 15.15, June 2017) + +MindArk made significant changes to how armors work: + +### Key Changes: +1. **Armor decays significantly less** per point of damage absorbed +2. **No minimum decay** based on total protection - always proportional to damage absorbed +3. **Armor absorbs ALL incoming damage** - no more 1.0 damage taken on full absorption (shown as "Deflected" message) +4. **Armor and platings decay independently** based on actual damage each absorbs, not both decaying as if absorbing full damage +5. **Armor decay is now linear** per point of damage absorbed (not increasing cost per damage) + +--- + +## Armor Economy (Durability) + +### Base Protection Cost: +**20 hp / pec** - This is the standard armor durability baseline. + +### Comparison with Healing Tools: +| Protection Method | Economy | +|-------------------|---------| +| Armor (base) | ~20 hp/pec | +| EMT Kit Ek-2600 Improved | 20 hp/pec | +| Refurbished H.E.A.R.T Rank VI | 18 hp/pec | +| Vivo S10 | 12.31 hp/pec | +| Herb Box | 10.06 hp/pec | +| EMT Kit Ek-2350 | 4 hp/pec | + +**Conclusion:** Armor is very economical compared to most healing tools. + +--- + +## Armor Progression Guide + +### 1. First Armor: Graduation Set +- Get by graduating with a Mentor +- Each planet has unique graduation armor +- **Best choice:** Atlas (NI) or Musca Adjusted (Arkadia) + +### 2. Early Game: Before Ghost +**Add plates first!** Get 5B plates for your graduation armor. + +Then choose: +- **Gremlin** - Most versatile armor +- **Nemesis** - Upgradeable to Adjusted Nemesis (robot armor) +- **Ghost** - Most protection for the money (recommended) + +### 3. Mid Game: After Ghost+5B +Options (price jump!): +- **Adjusted Nemesis** (~2.5k PED) - Good for robots +- **Lion** (~5-6.5k PED) - Robot Beacon drops +- **Adjusted Boar** (~7.5-9.5k PED) - 30 Impact, Dodge buff +- **Looted (L) pieces** - Cheap bridge option + +### 4. End Game +- Upgraded Angel +- Upgraded Gorgon +- Modified Viceroy (avatar bound, ~9k PED) + +--- + +## Damage Absorption Mechanics (Post-Loot 2.0) + +### How Protection Works: +1. **Plate takes damage first** (if equipped) +2. **Armor takes remaining damage** +3. **Player takes overflow** (if any) + +### Decay Calculation: +``` +Plate decay = (damage_absorbed_by_plate / total_plate_protection) × plate_decay_rate +Armor decay = (damage_absorbed_by_armor / total_armor_protection) × armor_decay_rate +``` + +**Key Point:** Plate and armor decay **independently** based on actual damage each absorbs. + +### Example: +- Monster hits: 20 Impact damage +- Plate protects: 15 Impact +- Armor protects: 5 Impact (remaining) +- Player takes: 0 damage (deflected) + +**Decay:** +- Plate decays for absorbing 15 damage +- Armor decays for absorbing 5 damage + +--- + +## Armor Buffs + +Many armor sets offer buffs: +- **20% increased Dodge chance** +- **10-20% increased Evade chance** +- **3% faster reload speed** +- **5% Acceleration** +- **1-4% Block chance** +- Heal over time + +**Block Chance:** Some upgraded plates have Block Chance - when triggered, hit is nullified (no damage, no decay). + +--- + +## Armor Sets Reference + +### Entry Level: +- **Gremlin** - Most versatile early armor +- **Ghost** - Best protection for price +- **Nemesis** - Robot hunting, upgradeable + +### Mid Level: +- **Adjusted Nemesis** - Robot specialist +- **Lion** - Stab-focused protection +- **Adjusted Boar** - 30 Impact, Dodge buff + +### High Level: +- **Angel** (upgraded) +- **Gorgon** (upgraded) +- **Modified Viceroy** (avatar bound) + +--- + +## Plating Strategy + +### General Rule: +- **5B plates** are the standard early-game addition +- Match plates to mob's damage type +- All 7 plates should match (don't mix types) + +### Plate Types: +- **5B** - General purpose (Impact/Stab/Cut) +- **6A** - Cold/Electric protection +- **5C** - Burn/Acid protection +- Custom plates for specific mobs + +--- + +## Implementation Notes for Lemontropia Suite + +1. **Decay Formula:** Linear per damage point absorbed +2. **Plate Priority:** Plates absorb first, then armor +3. **Independent Decay:** Each piece decays separately +4. **Deflected Message:** Indicates full absorption (0 player damage) +5. **Block Chance:** Roll for block before applying damage +6. **Protection Stacking:** Plate + Armor protection values add + +--- + +## Key Formulas + +### Cost Per Hit: +``` +armor_cost = (damage_absorbed_by_armor / armor_protection) × armor_decay_per_pec +plate_cost = (damage_absorbed_by_plate / plate_protection) × plate_decay_per_pec +total_cost = armor_cost + plate_cost +``` + +### Protection Calculation: +``` +total_protection = armor_protection + plate_protection +if total_protection >= damage: + player_damage = 0 + absorbed_by_plate = min(damage, plate_protection) + absorbed_by_armor = damage - absorbed_by_plate +else: + player_damage = damage - total_protection + absorbed_by_plate = plate_protection + absorbed_by_armor = armor_protection +``` \ No newline at end of file diff --git a/ui/gear_selector.py b/ui/gear_selector.py index 859de00..33c1f34 100644 --- a/ui/gear_selector.py +++ b/ui/gear_selector.py @@ -13,7 +13,7 @@ from PyQt6.QtCore import Qt, pyqtSignal, QThread from decimal import Decimal from typing import Optional, List -from core.nexus_api import EntropiaNexusAPI, WeaponStats, ArmorStats, FinderStats +from core.nexus_api import EntropiaNexusAPI, WeaponStats, ArmorStats, FinderStats, MedicalTool class WeaponLoaderThread(QThread): @@ -61,6 +61,21 @@ class FinderLoaderThread(QThread): self.error_occurred.emit(str(e)) +class MedicalToolLoaderThread(QThread): + """Thread to load medical tools (FAPs) from API.""" + + medical_tools_loaded = pyqtSignal(list) + error_occurred = pyqtSignal(str) + + def run(self): + try: + api = EntropiaNexusAPI() + tools = api.get_all_medical_tools() + self.medical_tools_loaded.emit(tools) + except Exception as e: + self.error_occurred.emit(str(e)) + + class GearSelectorDialog(QDialog): """Dialog for selecting gear from Entropia Nexus.""" diff --git a/ui/main_window.py b/ui/main_window.py index 8bea2d8..4494d4c 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -258,6 +258,9 @@ class MainWindow(QMainWindow): 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 # Setup UI self.setup_ui() @@ -948,6 +951,34 @@ class MainWindow(QMainWindow): 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')) + + # 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 + self.hud.on_heal_event(heal_amount, decay_cost) + + # Log to UI + self.log_info("Heal", f"Healed {heal_amount} HP (Cost: {decay_cost:.4f} PED)") + def on_loot(event): """Handle loot events.""" item_name = event.data.get('item_name', 'Unknown') @@ -1059,6 +1090,7 @@ class MainWindow(QMainWindow): 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."""