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:
2025-11-26 15:43:20 -06:00
parent 30c3b800e6
commit 03ab783eeb
22 changed files with 9091 additions and 5 deletions

View 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