Combat Backend & Data Models
- Implement Combat Service - Implement Damage Calculator - Implement Effect Processor - Implement Combat Actions - Created Combat API Endpoints
This commit is contained in:
677
api/tests/test_damage_calculator.py
Normal file
677
api/tests/test_damage_calculator.py
Normal file
@@ -0,0 +1,677 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user