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
This commit is contained in:
2025-11-26 19:54:58 -06:00
parent 4ced1b04df
commit a38906b445
16 changed files with 792 additions and 168 deletions

View File

@@ -267,8 +267,9 @@ class TestPhysicalDamage:
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
# 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
@@ -278,10 +279,9 @@ class TestPhysicalDamage:
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
# 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
@@ -289,7 +289,7 @@ class TestPhysicalDamage:
def test_physical_damage_miss(self):
"""Test that misses deal zero damage."""
attacker = Stats(strength=14, luck=0)
attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus
defender = Stats(dexterity=30) # Very high DEX
# Force a miss
@@ -297,7 +297,6 @@ class TestPhysicalDamage:
result = DamageCalculator.calculate_physical_damage(
attacker_stats=attacker,
defender_stats=defender,
weapon_damage=8,
)
assert result.is_miss is True
@@ -306,7 +305,7 @@ class TestPhysicalDamage:
def test_physical_damage_critical_hit(self):
"""Test critical hit doubles damage."""
attacker = Stats(strength=14, luck=20) # High LUK for crit
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
@@ -315,15 +314,14 @@ class TestPhysicalDamage:
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
# 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()
@@ -405,7 +403,8 @@ class TestElementalWeaponDamage:
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)
# 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):
@@ -414,7 +413,6 @@ class TestElementalWeaponDamage:
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,
@@ -422,9 +420,10 @@ class TestElementalWeaponDamage:
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)
# 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
@@ -433,7 +432,8 @@ class TestElementalWeaponDamage:
def test_50_50_split_damage(self):
"""Test 50/50 physical/elemental split (Lightning Spear)."""
attacker = Stats(strength=12, intelligence=12, luck=0)
# 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):
@@ -442,7 +442,6 @@ class TestElementalWeaponDamage:
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,
@@ -450,12 +449,12 @@ class TestElementalWeaponDamage:
elemental_type=DamageType.LIGHTNING,
)
# Both components should be similar (same stat values)
# 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)
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
@@ -464,7 +463,6 @@ class TestElementalWeaponDamage:
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,
@@ -614,8 +612,8 @@ class TestCombatIntegration:
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)
# 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):
@@ -624,15 +622,14 @@ class TestCombatIntegration:
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
# 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
# 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
@@ -646,14 +643,15 @@ class TestCombatIntegration:
damage_type=DamageType.FIRE,
)
# 12 + (15 * 0.75) = 23.25 -> 23 - 5 = 18
# 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) # Melee
arcanist = Stats(intelligence=15, luck=9) # Caster
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):
@@ -662,7 +660,6 @@ class TestCombatIntegration:
phys_result = DamageCalculator.calculate_physical_damage(
attacker_stats=vanguard,
defender_stats=target,
weapon_damage=8,
)
magic_result = DamageCalculator.calculate_magical_damage(
attacker_stats=arcanist,