""" Damage Calculator Service A comprehensive, formula-driven damage calculation system for Code of Conquest. Handles physical, magical, and elemental damage with LUK stat integration for variance, critical hits, and accuracy. Formulas: Physical: (effective_stats.damage + ability_power) * Variance * Crit_Mult - DEF where effective_stats.damage = int(STR * 0.75) + damage_bonus (from weapon) Magical: (effective_stats.spell_power + ability_power) * Variance * Crit_Mult - RES where effective_stats.spell_power = int(INT * 0.75) + spell_power_bonus (from staff/wand) Elemental: Split between physical and magical components using ratios LUK Integration: - Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss - Crit bonus: Base 5% + (LUK * 0.5%), max 25% - Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll """ import random from dataclasses import dataclass, field from typing import Dict, Any, Optional, List from app.models.stats import Stats from app.models.enums import DamageType class CombatConstants: """ Combat system tuning constants. These values control the balance of combat mechanics and can be adjusted for game balance without modifying formula logic. """ # Stat Scaling # How much primary stats (STR/INT) contribute to damage # 0.75 means STR 14 adds +10.5 damage STAT_SCALING_FACTOR: float = 0.75 # Hit/Miss System BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10 MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss # Critical Hits DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills) DEFAULT_CRIT_MULTIPLIER: float = 2.0 # Damage Variance BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus) BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point # Defense Mitigation # Ensures high-DEF targets still take meaningful damage MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through MIN_DAMAGE: int = 1 # Absolute minimum damage @dataclass class DamageResult: """ Result of a damage calculation. Contains the calculated damage values, whether the attack was a crit or miss, and a human-readable message for the combat log. Attributes: total_damage: Final damage after all calculations physical_damage: Physical component (for split damage) elemental_damage: Elemental component (for split damage) damage_type: Primary damage type (physical, fire, etc.) is_critical: Whether the attack was a critical hit is_miss: Whether the attack missed entirely variance_roll: The variance multiplier that was applied raw_damage: Damage before defense mitigation message: Human-readable description for combat log """ total_damage: int = 0 physical_damage: int = 0 elemental_damage: int = 0 damage_type: DamageType = DamageType.PHYSICAL elemental_type: Optional[DamageType] = None is_critical: bool = False is_miss: bool = False variance_roll: float = 1.0 raw_damage: int = 0 message: str = "" def to_dict(self) -> Dict[str, Any]: """Serialize damage result to dictionary.""" return { "total_damage": self.total_damage, "physical_damage": self.physical_damage, "elemental_damage": self.elemental_damage, "damage_type": self.damage_type.value if self.damage_type else "physical", "elemental_type": self.elemental_type.value if self.elemental_type else None, "is_critical": self.is_critical, "is_miss": self.is_miss, "variance_roll": round(self.variance_roll, 3), "raw_damage": self.raw_damage, "message": self.message, } class DamageCalculator: """ Formula-driven damage calculator for combat. This class provides static methods for calculating all types of damage in the combat system, including hit/miss chances, critical hits, damage variance, and defense mitigation. All formulas integrate the LUK stat for meaningful randomness while maintaining a hard cap on miss chance to prevent frustration. """ @staticmethod def calculate_hit_chance( attacker_luck: int, defender_dexterity: int, skill_bonus: float = 0.0 ) -> float: """ Calculate hit probability for an attack. Formula: miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025)) hit_chance = 1.0 - miss_chance Args: attacker_luck: Attacker's LUK stat defender_dexterity: Defender's DEX stat skill_bonus: Additional hit chance from skills (0.0 to 1.0) Returns: Hit probability as a float between 0.0 and 1.0 Examples: LUK 8, DEX 10: miss = 10% - 4% + 0% = 6% LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5% LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25% """ # Base miss rate base_miss = CombatConstants.BASE_MISS_CHANCE # LUK reduces miss chance luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION # High DEX increases evasion (only DEX above 10 counts) dex_above_base = max(0, defender_dexterity - 10) dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS # Calculate final miss chance with hard cap miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance) return 1.0 - miss_chance @staticmethod def calculate_crit_chance( attacker_luck: int, weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, skill_bonus: float = 0.0 ) -> float: """ Calculate critical hit probability. Formula: crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus) Args: attacker_luck: Attacker's LUK stat weapon_crit_chance: Base crit chance from weapon (default 5%) skill_bonus: Additional crit chance from skills Returns: Crit probability as a float (capped at 25%) Examples: LUK 8, weapon 5%: crit = 5% + 4% = 9% LUK 12, weapon 5%: crit = 5% + 6% = 11% LUK 12, weapon 10%: crit = 10% + 6% = 16% """ # LUK bonus to crit luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS # Total crit chance with cap total_crit = weapon_crit_chance + luk_bonus + skill_bonus return min(CombatConstants.MAX_CRIT_CHANCE, total_crit) @staticmethod def calculate_variance(attacker_luck: int) -> float: """ Calculate damage variance multiplier with LUK bonus. Hybrid variance system: - Base roll: 95% to 105% of damage - LUK grants chance for "lucky roll": 100% to 110% instead Args: attacker_luck: Attacker's LUK stat Returns: Variance multiplier (typically 0.95 to 1.10) Examples: LUK 8: 7% chance for lucky roll (100-110%) LUK 12: 8% chance for lucky roll """ # Calculate lucky roll chance lucky_chance = ( CombatConstants.BASE_LUCKY_CHANCE + (attacker_luck * CombatConstants.LUK_LUCKY_BONUS) ) # Roll for lucky variance if random.random() < lucky_chance: # Lucky roll: higher damage range return random.uniform( CombatConstants.LUCKY_VARIANCE_MIN, CombatConstants.LUCKY_VARIANCE_MAX ) else: # Normal roll return random.uniform( CombatConstants.BASE_VARIANCE_MIN, CombatConstants.BASE_VARIANCE_MAX ) @staticmethod def apply_defense( raw_damage: int, defense: int, min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO ) -> int: """ Apply defense mitigation with minimum damage guarantee. Ensures at least 20% of raw damage always goes through, preventing high-DEF tanks from becoming unkillable. Absolute minimum is always 1 damage. Args: raw_damage: Damage before defense defense: Target's defense value min_damage_ratio: Minimum % of raw damage that goes through Returns: Final damage after mitigation (minimum 1) Examples: raw=20, def=5: 20 - 5 = 15 damage raw=20, def=18: max(4, 2) = 4 damage (20% minimum) raw=10, def=100: max(2, -90) = 2 damage (20% minimum) """ # Calculate mitigated damage mitigated = raw_damage - defense # Minimum damage is 20% of raw, or 1, whichever is higher min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio)) return max(min_damage, mitigated) @classmethod def calculate_physical_damage( cls, attacker_stats: Stats, defender_stats: Stats, weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER, ability_base_power: int = 0, skill_hit_bonus: float = 0.0, skill_crit_bonus: float = 0.0, ) -> DamageResult: """ Calculate physical damage for a melee/ranged attack. Formula: Base = attacker_stats.damage + ability_base_power where attacker_stats.damage = int(STR * 0.75) + damage_bonus Damage = Base * Variance * Crit_Mult - DEF Args: attacker_stats: Attacker's Stats (includes weapon damage via damage property) defender_stats: Defender's Stats (DEX, CON used) weapon_crit_chance: Crit chance from weapon (default 5%) weapon_crit_multiplier: Crit damage multiplier (default 2.0x) ability_base_power: Additional base power from ability skill_hit_bonus: Hit chance bonus from skills skill_crit_bonus: Crit chance bonus from skills Returns: DamageResult with calculated damage and metadata """ result = DamageResult(damage_type=DamageType.PHYSICAL) # Step 1: Check for miss hit_chance = cls.calculate_hit_chance( attacker_stats.luck, defender_stats.dexterity, skill_hit_bonus ) if random.random() > hit_chance: result.is_miss = True result.message = "Attack missed!" return result # Step 2: Calculate base damage # attacker_stats.damage already includes: int(STR * 0.75) + damage_bonus (weapon) base_damage = attacker_stats.damage + ability_base_power # Step 3: Apply variance variance = cls.calculate_variance(attacker_stats.luck) result.variance_roll = variance damage = base_damage * variance # Step 4: Check for critical hit crit_chance = cls.calculate_crit_chance( attacker_stats.luck, weapon_crit_chance, skill_crit_bonus ) if random.random() < crit_chance: result.is_critical = True damage *= weapon_crit_multiplier # Store raw damage before defense result.raw_damage = int(damage) # Step 5: Apply defense mitigation final_damage = cls.apply_defense(int(damage), defender_stats.defense) result.total_damage = final_damage result.physical_damage = final_damage # Build message crit_text = " CRITICAL HIT!" if result.is_critical else "" result.message = f"Dealt {final_damage} physical damage.{crit_text}" return result @classmethod def calculate_magical_damage( cls, attacker_stats: Stats, defender_stats: Stats, ability_base_power: int, damage_type: DamageType = DamageType.FIRE, weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER, skill_hit_bonus: float = 0.0, skill_crit_bonus: float = 0.0, ) -> DamageResult: """ Calculate magical damage for a spell. Spells CAN critically hit (same formula as physical). LUK benefits all classes equally. Formula: Base = attacker_stats.spell_power + ability_base_power where attacker_stats.spell_power = int(INT * 0.75) + spell_power_bonus Damage = Base * Variance * Crit_Mult - RES Args: attacker_stats: Attacker's Stats (includes staff/wand spell_power via spell_power property) defender_stats: Defender's Stats (DEX, WIS used) ability_base_power: Base power of the spell damage_type: Type of magical damage (fire, ice, etc.) weapon_crit_chance: Crit chance (from focus/staff) weapon_crit_multiplier: Crit damage multiplier skill_hit_bonus: Hit chance bonus from skills skill_crit_bonus: Crit chance bonus from skills Returns: DamageResult with calculated damage and metadata """ result = DamageResult(damage_type=damage_type) # Step 1: Check for miss (spells can miss too) hit_chance = cls.calculate_hit_chance( attacker_stats.luck, defender_stats.dexterity, skill_hit_bonus ) if random.random() > hit_chance: result.is_miss = True result.message = "Spell missed!" return result # Step 2: Calculate base damage # attacker_stats.spell_power already includes: int(INT * 0.75) + spell_power_bonus (staff/wand) base_damage = attacker_stats.spell_power + ability_base_power # Step 3: Apply variance variance = cls.calculate_variance(attacker_stats.luck) result.variance_roll = variance damage = base_damage * variance # Step 4: Check for critical hit (spells CAN crit) crit_chance = cls.calculate_crit_chance( attacker_stats.luck, weapon_crit_chance, skill_crit_bonus ) if random.random() < crit_chance: result.is_critical = True damage *= weapon_crit_multiplier # Store raw damage before resistance result.raw_damage = int(damage) # Step 5: Apply resistance mitigation final_damage = cls.apply_defense(int(damage), defender_stats.resistance) result.total_damage = final_damage result.elemental_damage = final_damage # Build message crit_text = " CRITICAL HIT!" if result.is_critical else "" result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}" return result @classmethod def calculate_elemental_weapon_damage( cls, attacker_stats: Stats, defender_stats: Stats, weapon_crit_chance: float, weapon_crit_multiplier: float, physical_ratio: float, elemental_ratio: float, elemental_type: DamageType, ability_base_power: int = 0, skill_hit_bonus: float = 0.0, skill_crit_bonus: float = 0.0, ) -> DamageResult: """ Calculate split damage for elemental weapons (e.g., Fire Sword). Elemental weapons deal both physical AND elemental damage, calculated separately against DEF and RES respectively. Formula: Physical = (attacker_stats.damage + ability_power) * PHYS_RATIO - DEF Elemental = (attacker_stats.spell_power + ability_power) * ELEM_RATIO - RES Total = Physical + Elemental Recommended Split Ratios: - Pure Physical: 100% / 0% - Fire Sword: 70% / 30% - Frost Blade: 60% / 40% - Lightning Spear: 50% / 50% Args: attacker_stats: Attacker's Stats (damage and spell_power include equipment) defender_stats: Defender's Stats weapon_crit_chance: Crit chance from weapon weapon_crit_multiplier: Crit damage multiplier physical_ratio: Portion of damage that is physical (0.0-1.0) elemental_ratio: Portion of damage that is elemental (0.0-1.0) elemental_type: Type of elemental damage ability_base_power: Additional base power from ability skill_hit_bonus: Hit chance bonus from skills skill_crit_bonus: Crit chance bonus from skills Returns: DamageResult with split physical/elemental damage """ result = DamageResult( damage_type=DamageType.PHYSICAL, elemental_type=elemental_type ) # Step 1: Check for miss (single roll for entire attack) hit_chance = cls.calculate_hit_chance( attacker_stats.luck, defender_stats.dexterity, skill_hit_bonus ) if random.random() > hit_chance: result.is_miss = True result.message = "Attack missed!" return result # Step 2: Check for critical (single roll applies to both components) variance = cls.calculate_variance(attacker_stats.luck) result.variance_roll = variance crit_chance = cls.calculate_crit_chance( attacker_stats.luck, weapon_crit_chance, skill_crit_bonus ) is_crit = random.random() < crit_chance result.is_critical = is_crit crit_mult = weapon_crit_multiplier if is_crit else 1.0 # Step 3: Calculate physical component # attacker_stats.damage includes: int(STR * 0.75) + damage_bonus (weapon) phys_base = (attacker_stats.damage + ability_base_power) * physical_ratio phys_damage = phys_base * variance * crit_mult phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense) # Step 4: Calculate elemental component # attacker_stats.spell_power includes: int(INT * 0.75) + spell_power_bonus (staff/wand) elem_base = (attacker_stats.spell_power + ability_base_power) * elemental_ratio elem_damage = elem_base * variance * crit_mult elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance) # Step 5: Combine results result.physical_damage = phys_final result.elemental_damage = elem_final result.total_damage = phys_final + elem_final result.raw_damage = int(phys_damage + elem_damage) # Build message crit_text = " CRITICAL HIT!" if is_crit else "" result.message = ( f"Dealt {result.total_damage} damage " f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}" ) return result @classmethod def calculate_aoe_damage( cls, attacker_stats: Stats, defender_stats_list: List[Stats], ability_base_power: int, damage_type: DamageType = DamageType.FIRE, weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER, skill_hit_bonus: float = 0.0, skill_crit_bonus: float = 0.0, ) -> List[DamageResult]: """ Calculate AoE spell damage against multiple targets. AoE spells deal FULL damage to all targets (balanced by higher mana costs). Each target has independent hit/crit rolls but shares the base calculation. Args: attacker_stats: Attacker's Stats defender_stats_list: List of defender Stats (one per target) ability_base_power: Base power of the AoE spell damage_type: Type of magical damage weapon_crit_chance: Crit chance from focus/staff weapon_crit_multiplier: Crit damage multiplier skill_hit_bonus: Hit chance bonus from skills skill_crit_bonus: Crit chance bonus from skills Returns: List of DamageResult, one per target """ results = [] # Each target gets independent damage calculation for defender_stats in defender_stats_list: result = cls.calculate_magical_damage( attacker_stats=attacker_stats, defender_stats=defender_stats, ability_base_power=ability_base_power, damage_type=damage_type, weapon_crit_chance=weapon_crit_chance, weapon_crit_multiplier=weapon_crit_multiplier, skill_hit_bonus=skill_hit_bonus, skill_crit_bonus=skill_crit_bonus, ) results.append(result) return results