Equipment-Combat Integration: - Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling - Add spell_power system for magical weapons (staves, wands) - Add spell_power_bonus field to Stats model with spell_power property - Add spell_power field to Item model with is_magical_weapon() method - Update Character.get_effective_stats() to populate spell_power_bonus Combatant Model Updates: - Add weapon property fields (crit_chance, crit_multiplier, damage_type) - Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio) - Update serialization to handle new weapon properties DamageCalculator Refactoring: - Remove weapon_damage parameter from calculate_physical_damage() - Use attacker_stats.damage directly (includes weapon bonus) - Use attacker_stats.spell_power for magical damage calculations Combat Service Updates: - Extract weapon properties in _create_combatant_from_character() - Use stats.damage_bonus for enemy combatants from templates - Remove hardcoded _get_weapon_damage() method - Handle elemental weapons with split damage in _execute_attack() Item Generation Updates: - Add base_spell_power to BaseItemTemplate dataclass - Add ARCANE damage type to DamageType enum - Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand) Test Updates: - Update test_stats.py for new damage formula (0.75 scaling) - Update test_character.py for equipment bonus calculations - Update test_damage_calculator.py for new API signatures - Update test_combat_service.py mock fixture for equipped attribute Tests: 174 passing
675 lines
28 KiB
Python
675 lines
28 KiB
Python
"""
|
|
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: (stats.damage + ability_power) * Variance - DEF
|
|
# where stats.damage = int(STR * 0.75) + damage_bonus
|
|
attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus
|
|
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,
|
|
)
|
|
|
|
# int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 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, damage_bonus=8) # Weapon damage in bonus
|
|
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,
|
|
)
|
|
|
|
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, damage_bonus=8) # High LUK for crit, weapon in bonus
|
|
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_crit_multiplier=2.0,
|
|
)
|
|
|
|
assert result.is_critical is True
|
|
# Base: int(14 * 0.75) + 8 = 10 + 8 = 18
|
|
# Crit: 18 * 2 = 36
|
|
# After DEF 5: 36 - 5 = 31
|
|
assert result.total_damage == 31
|
|
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
|
|
# Weapon contributes to both damage_bonus (physical) and spell_power_bonus (elemental)
|
|
attacker = Stats(strength=14, intelligence=8, luck=0, damage_bonus=15, spell_power_bonus=15)
|
|
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_crit_chance=0.05,
|
|
weapon_crit_multiplier=2.0,
|
|
physical_ratio=0.7,
|
|
elemental_ratio=0.3,
|
|
elemental_type=DamageType.FIRE,
|
|
)
|
|
|
|
# stats.damage = int(14 * 0.75) + 15 = 10 + 15 = 25
|
|
# stats.spell_power = int(8 * 0.75) + 15 = 6 + 15 = 21
|
|
# Physical: 25 * 0.7 = 17.5 -> 17 - 5 DEF = 12
|
|
# Elemental: 21 * 0.3 = 6.3 -> 6 - 5 RES = 1
|
|
|
|
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)."""
|
|
# Same stats and weapon bonuses means similar damage on both sides
|
|
attacker = Stats(strength=12, intelligence=12, luck=0, damage_bonus=20, spell_power_bonus=20)
|
|
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_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 and weapon bonuses)
|
|
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, damage_bonus=15, spell_power_bonus=15)
|
|
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_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, equipped with Rusty Sword (8 damage)
|
|
vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8, damage_bonus=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,
|
|
)
|
|
|
|
# int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 13
|
|
assert result.total_damage == 13
|
|
|
|
def test_arcanist_fireball_scenario(self):
|
|
"""Test Arcanist (INT 15) Fireball."""
|
|
# Arcanist: INT 15, LUK 9 (no staff equipped, pure ability damage)
|
|
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,
|
|
)
|
|
|
|
# stats.spell_power = int(15 * 0.75) + 0 = 11
|
|
# 11 + 12 (ability) = 23 - 5 RES = 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, damage_bonus=8) # Melee with weapon
|
|
arcanist = Stats(intelligence=15, luck=9) # Caster (no staff)
|
|
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,
|
|
)
|
|
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
|