""" Unit tests for the DamageCalculator service. Tests cover: - Hit chance calculations with LUK/DEX - Critical hit chance calculations - Damage variance with lucky rolls - Physical damage formula - Magical damage formula - Elemental split damage - Defense mitigation with minimum guarantee - AoE damage calculations """ import pytest import random from unittest.mock import patch from app.models.stats import Stats from app.models.enums import DamageType from app.services.damage_calculator import ( DamageCalculator, DamageResult, CombatConstants, ) # ============================================================================= # Hit Chance Tests # ============================================================================= class TestHitChance: """Tests for calculate_hit_chance().""" def test_base_hit_chance_with_average_stats(self): """Test hit chance with average LUK (8) and DEX (10).""" # LUK 8: miss = 10% - 4% = 6% hit_chance = DamageCalculator.calculate_hit_chance( attacker_luck=8, defender_dexterity=10, ) assert hit_chance == pytest.approx(0.94, abs=0.001) def test_high_luck_reduces_miss_chance(self): """Test that high LUK reduces miss chance.""" # LUK 12: miss = 10% - 6% = 4%, but capped at 5% hit_chance = DamageCalculator.calculate_hit_chance( attacker_luck=12, defender_dexterity=10, ) assert hit_chance == pytest.approx(0.95, abs=0.001) def test_miss_chance_hard_cap_at_five_percent(self): """Test that miss chance cannot go below 5% (hard cap).""" # LUK 20: would be 10% - 10% = 0%, but capped at 5% hit_chance = DamageCalculator.calculate_hit_chance( attacker_luck=20, defender_dexterity=10, ) assert hit_chance == pytest.approx(0.95, abs=0.001) def test_high_dex_increases_evasion(self): """Test that defender's high DEX increases miss chance.""" # LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25% hit_chance = DamageCalculator.calculate_hit_chance( attacker_luck=8, defender_dexterity=15, ) assert hit_chance == pytest.approx(0.9275, abs=0.001) def test_dex_below_ten_has_no_evasion_bonus(self): """Test that DEX below 10 doesn't reduce attacker's hit chance.""" # DEX 5 should be same as DEX 10 (no negative evasion) hit_low_dex = DamageCalculator.calculate_hit_chance( attacker_luck=8, defender_dexterity=5, ) hit_base_dex = DamageCalculator.calculate_hit_chance( attacker_luck=8, defender_dexterity=10, ) assert hit_low_dex == hit_base_dex def test_skill_bonus_improves_hit_chance(self): """Test that skill bonus adds to hit chance.""" base_hit = DamageCalculator.calculate_hit_chance( attacker_luck=8, defender_dexterity=10, ) skill_hit = DamageCalculator.calculate_hit_chance( attacker_luck=8, defender_dexterity=10, skill_bonus=0.05, # 5% bonus ) assert skill_hit > base_hit # ============================================================================= # Critical Hit Tests # ============================================================================= class TestCritChance: """Tests for calculate_crit_chance().""" def test_base_crit_with_average_luck(self): """Test crit chance with average LUK (8).""" # Base 5% + LUK 8 * 0.5% = 5% + 4% = 9% crit_chance = DamageCalculator.calculate_crit_chance( attacker_luck=8, ) assert crit_chance == pytest.approx(0.09, abs=0.001) def test_high_luck_increases_crit(self): """Test that high LUK increases crit chance.""" # Base 5% + LUK 12 * 0.5% = 5% + 6% = 11% crit_chance = DamageCalculator.calculate_crit_chance( attacker_luck=12, ) assert crit_chance == pytest.approx(0.11, abs=0.001) def test_weapon_crit_stacks_with_luck(self): """Test that weapon crit chance stacks with LUK bonus.""" # Weapon 10% + LUK 12 * 0.5% = 10% + 6% = 16% crit_chance = DamageCalculator.calculate_crit_chance( attacker_luck=12, weapon_crit_chance=0.10, ) assert crit_chance == pytest.approx(0.16, abs=0.001) def test_crit_chance_hard_cap_at_25_percent(self): """Test that crit chance is capped at 25%.""" # Weapon 20% + LUK 20 * 0.5% = 20% + 10% = 30%, but capped at 25% crit_chance = DamageCalculator.calculate_crit_chance( attacker_luck=20, weapon_crit_chance=0.20, ) assert crit_chance == pytest.approx(0.25, abs=0.001) def test_skill_bonus_adds_to_crit(self): """Test that skill bonus adds to crit chance.""" base_crit = DamageCalculator.calculate_crit_chance( attacker_luck=8, ) skill_crit = DamageCalculator.calculate_crit_chance( attacker_luck=8, skill_bonus=0.05, ) assert skill_crit == base_crit + 0.05 # ============================================================================= # Damage Variance Tests # ============================================================================= class TestDamageVariance: """Tests for calculate_variance().""" @patch('random.random') @patch('random.uniform') def test_normal_variance_roll(self, mock_uniform, mock_random): """Test normal variance roll (95%-105%).""" # Not a lucky roll (random returns high value) mock_random.return_value = 0.99 mock_uniform.return_value = 1.0 variance = DamageCalculator.calculate_variance(attacker_luck=8) # Should call uniform with base variance range mock_uniform.assert_called_with( CombatConstants.BASE_VARIANCE_MIN, CombatConstants.BASE_VARIANCE_MAX, ) assert variance == 1.0 @patch('random.random') @patch('random.uniform') def test_lucky_variance_roll(self, mock_uniform, mock_random): """Test lucky variance roll (100%-110%).""" # Lucky roll (random returns low value) mock_random.return_value = 0.01 mock_uniform.return_value = 1.08 variance = DamageCalculator.calculate_variance(attacker_luck=8) # Should call uniform with lucky variance range mock_uniform.assert_called_with( CombatConstants.LUCKY_VARIANCE_MIN, CombatConstants.LUCKY_VARIANCE_MAX, ) assert variance == 1.08 def test_high_luck_increases_lucky_chance(self): """Test that high LUK increases chance for lucky roll.""" # LUK 8: lucky chance = 5% + 2% = 7% # LUK 12: lucky chance = 5% + 3% = 8% # Run many iterations to verify probability lucky_count_low = 0 lucky_count_high = 0 iterations = 10000 random.seed(42) # Reproducible for _ in range(iterations): variance = DamageCalculator.calculate_variance(8) if variance >= 1.0: lucky_count_low += 1 random.seed(42) # Same seed for _ in range(iterations): variance = DamageCalculator.calculate_variance(12) if variance >= 1.0: lucky_count_high += 1 # Higher LUK should have more lucky rolls # Note: This is a statistical test, might have some variance # Just verify the high LUK isn't dramatically lower assert lucky_count_high >= lucky_count_low * 0.9 # ============================================================================= # Defense Mitigation Tests # ============================================================================= class TestDefenseMitigation: """Tests for apply_defense().""" def test_normal_defense_mitigation(self): """Test standard defense subtraction.""" # 20 damage - 5 defense = 15 damage result = DamageCalculator.apply_defense(raw_damage=20, defense=5) assert result == 15 def test_minimum_damage_guarantee(self): """Test that minimum 20% damage always goes through.""" # 20 damage - 18 defense = 2 damage (but min is 20% of 20 = 4) result = DamageCalculator.apply_defense(raw_damage=20, defense=18) assert result == 4 def test_defense_higher_than_damage(self): """Test when defense exceeds raw damage.""" # 10 damage - 100 defense = -90, but min is 20% of 10 = 2 result = DamageCalculator.apply_defense(raw_damage=10, defense=100) assert result == 2 def test_absolute_minimum_damage_is_one(self): """Test that absolute minimum damage is 1.""" # 3 damage - 100 defense = negative, but 20% of 3 = 0.6, so min is 1 result = DamageCalculator.apply_defense(raw_damage=3, defense=100) assert result == 1 def test_custom_minimum_ratio(self): """Test custom minimum damage ratio.""" # 20 damage with 30% minimum = at least 6 damage result = DamageCalculator.apply_defense( raw_damage=20, defense=18, min_damage_ratio=0.30, ) assert result == 6 # ============================================================================= # Physical Damage Tests # ============================================================================= class TestPhysicalDamage: """Tests for calculate_physical_damage().""" def test_basic_physical_damage_formula(self): """Test the basic physical damage formula.""" # Formula: (Weapon + STR * 0.75) * Variance - DEF attacker = Stats(strength=14, luck=0) # LUK 0 to ensure no miss defender = Stats(constitution=10, dexterity=10) # DEF = 5 # Mock to ensure no miss and no crit, variance = 1.0 with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): result = DamageCalculator.calculate_physical_damage( attacker_stats=attacker, defender_stats=defender, weapon_damage=8, ) # 8 + (14 * 0.75) = 8 + 10.5 = 18.5 -> 18 - 5 = 13 assert result.total_damage == 13 assert result.is_miss is False assert result.is_critical is False assert result.damage_type == DamageType.PHYSICAL def test_physical_damage_miss(self): """Test that misses deal zero damage.""" attacker = Stats(strength=14, luck=0) defender = Stats(dexterity=30) # Very high DEX # Force a miss with patch('random.random', return_value=0.99): result = DamageCalculator.calculate_physical_damage( attacker_stats=attacker, defender_stats=defender, weapon_damage=8, ) assert result.is_miss is True assert result.total_damage == 0 assert "missed" in result.message.lower() def test_physical_damage_critical_hit(self): """Test critical hit doubles damage.""" attacker = Stats(strength=14, luck=20) # High LUK for crit defender = Stats(constitution=10, dexterity=10) # Force hit and crit with patch('random.random', side_effect=[0.01, 0.01]): # Hit, then crit with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): result = DamageCalculator.calculate_physical_damage( attacker_stats=attacker, defender_stats=defender, weapon_damage=8, weapon_crit_multiplier=2.0, ) assert result.is_critical is True # Base: 8 + 14*0.75 = 18.5 # Crit applied BEFORE int conversion: 18.5 * 2 = 37 # After DEF 5: 37 - 5 = 32 assert result.total_damage == 32 assert "critical" in result.message.lower() # ============================================================================= # Magical Damage Tests # ============================================================================= class TestMagicalDamage: """Tests for calculate_magical_damage().""" def test_basic_magical_damage_formula(self): """Test the basic magical damage formula.""" # Formula: (Ability + INT * 0.75) * Variance - RES attacker = Stats(intelligence=15, luck=0) defender = Stats(wisdom=10, dexterity=10) # RES = 5 with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): result = DamageCalculator.calculate_magical_damage( attacker_stats=attacker, defender_stats=defender, ability_base_power=12, damage_type=DamageType.FIRE, ) # 12 + (15 * 0.75) = 12 + 11.25 = 23.25 -> 23 - 5 = 18 assert result.total_damage == 18 assert result.damage_type == DamageType.FIRE assert result.is_miss is False def test_spells_can_critically_hit(self): """Test that spells can crit (per user requirement).""" attacker = Stats(intelligence=15, luck=20) defender = Stats(wisdom=10, dexterity=10) # Force hit and crit with patch('random.random', side_effect=[0.01, 0.01]): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): result = DamageCalculator.calculate_magical_damage( attacker_stats=attacker, defender_stats=defender, ability_base_power=12, damage_type=DamageType.FIRE, weapon_crit_multiplier=2.0, ) assert result.is_critical is True # Base: 12 + 15*0.75 = 23.25 -> 23 # Crit: 23 * 2 = 46 # After RES 5: 46 - 5 = 41 assert result.total_damage == 41 def test_magical_damage_with_different_types(self): """Test that different damage types are recorded correctly.""" attacker = Stats(intelligence=10) defender = Stats(wisdom=10, dexterity=10) with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): for damage_type in [DamageType.ICE, DamageType.LIGHTNING, DamageType.HOLY]: result = DamageCalculator.calculate_magical_damage( attacker_stats=attacker, defender_stats=defender, ability_base_power=10, damage_type=damage_type, ) assert result.damage_type == damage_type # ============================================================================= # Elemental Weapon (Split Damage) Tests # ============================================================================= class TestElementalWeaponDamage: """Tests for calculate_elemental_weapon_damage().""" def test_split_damage_calculation(self): """Test 70/30 physical/fire split damage.""" # Fire Sword: 70% physical, 30% fire attacker = Stats(strength=14, intelligence=8, luck=0) defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5 with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): result = DamageCalculator.calculate_elemental_weapon_damage( attacker_stats=attacker, defender_stats=defender, weapon_damage=15, weapon_crit_chance=0.05, weapon_crit_multiplier=2.0, physical_ratio=0.7, elemental_ratio=0.3, elemental_type=DamageType.FIRE, ) # Physical: (15 * 0.7) + (14 * 0.75 * 0.7) - 5 = 10.5 + 7.35 - 5 = 12.85 -> 12 # Elemental: (15 * 0.3) + (8 * 0.75 * 0.3) - 5 = 4.5 + 1.8 - 5 = 1.3 -> 1 # Total: 12 + 1 = 13 (approximately, depends on min damage) assert result.physical_damage > 0 assert result.elemental_damage >= 1 # At least minimum damage assert result.total_damage == result.physical_damage + result.elemental_damage assert result.elemental_type == DamageType.FIRE def test_50_50_split_damage(self): """Test 50/50 physical/elemental split (Lightning Spear).""" attacker = Stats(strength=12, intelligence=12, luck=0) defender = Stats(constitution=10, wisdom=10, dexterity=10) with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): result = DamageCalculator.calculate_elemental_weapon_damage( attacker_stats=attacker, defender_stats=defender, weapon_damage=20, weapon_crit_chance=0.05, weapon_crit_multiplier=2.0, physical_ratio=0.5, elemental_ratio=0.5, elemental_type=DamageType.LIGHTNING, ) # Both components should be similar (same stat values) assert abs(result.physical_damage - result.elemental_damage) <= 2 def test_elemental_crit_applies_to_both_components(self): """Test that crit multiplier applies to both damage types.""" attacker = Stats(strength=14, intelligence=8, luck=20) defender = Stats(constitution=10, wisdom=10, dexterity=10) # Force hit and crit with patch('random.random', side_effect=[0.01, 0.01]): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): result = DamageCalculator.calculate_elemental_weapon_damage( attacker_stats=attacker, defender_stats=defender, weapon_damage=15, weapon_crit_chance=0.05, weapon_crit_multiplier=2.0, physical_ratio=0.7, elemental_ratio=0.3, elemental_type=DamageType.FIRE, ) assert result.is_critical is True # Both components should be doubled # ============================================================================= # AoE Damage Tests # ============================================================================= class TestAoEDamage: """Tests for calculate_aoe_damage().""" def test_aoe_full_damage_to_all_targets(self): """Test that AoE deals full damage to each target.""" attacker = Stats(intelligence=15, luck=0) defenders = [ Stats(wisdom=10, dexterity=10), Stats(wisdom=10, dexterity=10), Stats(wisdom=10, dexterity=10), ] with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): results = DamageCalculator.calculate_aoe_damage( attacker_stats=attacker, defender_stats_list=defenders, ability_base_power=20, damage_type=DamageType.FIRE, ) assert len(results) == 3 # All targets should take the same damage (same stats) for result in results: assert result.total_damage == results[0].total_damage def test_aoe_independent_hit_checks(self): """Test that each target has independent hit/miss rolls.""" attacker = Stats(intelligence=15, luck=8) defenders = [ Stats(wisdom=10, dexterity=10), Stats(wisdom=10, dexterity=10), ] # First target hit, second target miss hit_sequence = [0.5, 0.99] # Below hit chance, above hit chance with patch('random.random', side_effect=hit_sequence * 2): # Extra for crit checks results = DamageCalculator.calculate_aoe_damage( attacker_stats=attacker, defender_stats_list=defenders, ability_base_power=20, damage_type=DamageType.FIRE, ) # At least verify we got results for both assert len(results) == 2 def test_aoe_with_varying_resistance(self): """Test that AoE respects different resistances per target.""" attacker = Stats(intelligence=15, luck=0) defenders = [ Stats(wisdom=10, dexterity=10), # RES = 5 Stats(wisdom=20, dexterity=10), # RES = 10 ] with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): results = DamageCalculator.calculate_aoe_damage( attacker_stats=attacker, defender_stats_list=defenders, ability_base_power=20, damage_type=DamageType.FIRE, ) # First target (lower RES) should take more damage assert results[0].total_damage > results[1].total_damage # ============================================================================= # DamageResult Tests # ============================================================================= class TestDamageResult: """Tests for DamageResult dataclass.""" def test_damage_result_to_dict(self): """Test serialization of DamageResult.""" result = DamageResult( total_damage=25, physical_damage=25, elemental_damage=0, damage_type=DamageType.PHYSICAL, is_critical=True, is_miss=False, variance_roll=1.05, raw_damage=30, message="Dealt 25 physical damage. CRITICAL HIT!", ) data = result.to_dict() assert data["total_damage"] == 25 assert data["physical_damage"] == 25 assert data["damage_type"] == "physical" assert data["is_critical"] is True assert data["is_miss"] is False assert data["variance_roll"] == pytest.approx(1.05, abs=0.001) # ============================================================================= # Combat Constants Tests # ============================================================================= class TestCombatConstants: """Tests for CombatConstants configuration.""" def test_stat_scaling_factor(self): """Verify scaling factor is 0.75.""" assert CombatConstants.STAT_SCALING_FACTOR == 0.75 def test_miss_chance_hard_cap(self): """Verify miss chance hard cap is 5%.""" assert CombatConstants.MIN_MISS_CHANCE == 0.05 def test_crit_chance_cap(self): """Verify crit chance cap is 25%.""" assert CombatConstants.MAX_CRIT_CHANCE == 0.25 def test_minimum_damage_ratio(self): """Verify minimum damage ratio is 20%.""" assert CombatConstants.MIN_DAMAGE_RATIO == 0.20 # ============================================================================= # Integration Tests (Full Combat Flow) # ============================================================================= class TestCombatIntegration: """Integration tests for complete combat scenarios.""" def test_vanguard_attack_scenario(self): """Test Vanguard (STR 14) basic attack.""" # Vanguard: STR 14, LUK 8 vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8) goblin = Stats(constitution=10, dexterity=10) # DEF = 5 with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): result = DamageCalculator.calculate_physical_damage( attacker_stats=vanguard, defender_stats=goblin, weapon_damage=8, # Rusty sword ) # 8 + (14 * 0.75) = 18.5 -> 18 - 5 = 13 assert result.total_damage == 13 def test_arcanist_fireball_scenario(self): """Test Arcanist (INT 15) Fireball.""" # Arcanist: INT 15, LUK 9 arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9) goblin = Stats(wisdom=10, dexterity=10) # RES = 5 with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): result = DamageCalculator.calculate_magical_damage( attacker_stats=arcanist, defender_stats=goblin, ability_base_power=12, # Fireball base damage_type=DamageType.FIRE, ) # 12 + (15 * 0.75) = 23.25 -> 23 - 5 = 18 assert result.total_damage == 18 def test_physical_vs_magical_balance(self): """Test that physical and magical damage are comparable.""" # Same-tier characters should deal similar damage vanguard = Stats(strength=14, luck=8) # Melee arcanist = Stats(intelligence=15, luck=9) # Caster target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5 with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): phys_result = DamageCalculator.calculate_physical_damage( attacker_stats=vanguard, defender_stats=target, weapon_damage=8, ) magic_result = DamageCalculator.calculate_magical_damage( attacker_stats=arcanist, defender_stats=target, ability_base_power=12, damage_type=DamageType.FIRE, ) # Mage should deal slightly more (compensates for mana cost) assert magic_result.total_damage >= phys_result.total_damage # But not drastically more (within ~50%) assert magic_result.total_damage <= phys_result.total_damage * 1.5