Files
Code_of_Conquest/api/tests/test_damage_calculator.py
Phillip Tarrant a38906b445 feat(api): integrate equipment stats into combat damage system
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
2025-11-26 19:54:58 -06:00

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