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
382 lines
10 KiB
Python
382 lines
10 KiB
Python
"""
|
||
Unit tests for Stats dataclass.
|
||
|
||
Tests computed properties, serialization, and basic operations.
|
||
"""
|
||
|
||
import pytest
|
||
from app.models.stats import Stats
|
||
|
||
|
||
def test_stats_default_values():
|
||
"""Test that Stats initializes with default values."""
|
||
stats = Stats()
|
||
|
||
assert stats.strength == 10
|
||
assert stats.dexterity == 10
|
||
assert stats.constitution == 10
|
||
assert stats.intelligence == 10
|
||
assert stats.wisdom == 10
|
||
assert stats.charisma == 10
|
||
|
||
|
||
def test_stats_custom_values():
|
||
"""Test creating Stats with custom values."""
|
||
stats = Stats(
|
||
strength=15,
|
||
dexterity=12,
|
||
constitution=14,
|
||
intelligence=8,
|
||
wisdom=10,
|
||
charisma=11,
|
||
)
|
||
|
||
assert stats.strength == 15
|
||
assert stats.dexterity == 12
|
||
assert stats.constitution == 14
|
||
assert stats.intelligence == 8
|
||
assert stats.wisdom == 10
|
||
assert stats.charisma == 11
|
||
|
||
|
||
def test_hit_points_calculation():
|
||
"""Test HP calculation: 10 + (constitution × 2)."""
|
||
stats = Stats(constitution=10)
|
||
assert stats.hit_points == 30 # 10 + (10 × 2)
|
||
|
||
stats = Stats(constitution=15)
|
||
assert stats.hit_points == 40 # 10 + (15 × 2)
|
||
|
||
stats = Stats(constitution=20)
|
||
assert stats.hit_points == 50 # 10 + (20 × 2)
|
||
|
||
|
||
def test_mana_points_calculation():
|
||
"""Test MP calculation: 10 + (intelligence × 2)."""
|
||
stats = Stats(intelligence=10)
|
||
assert stats.mana_points == 30 # 10 + (10 × 2)
|
||
|
||
stats = Stats(intelligence=15)
|
||
assert stats.mana_points == 40 # 10 + (15 × 2)
|
||
|
||
stats = Stats(intelligence=8)
|
||
assert stats.mana_points == 26 # 10 + (8 × 2)
|
||
|
||
|
||
def test_defense_calculation():
|
||
"""Test defense calculation: constitution // 2."""
|
||
stats = Stats(constitution=10)
|
||
assert stats.defense == 5 # 10 // 2
|
||
|
||
stats = Stats(constitution=15)
|
||
assert stats.defense == 7 # 15 // 2
|
||
|
||
stats = Stats(constitution=21)
|
||
assert stats.defense == 10 # 21 // 2
|
||
|
||
|
||
def test_resistance_calculation():
|
||
"""Test resistance calculation: wisdom // 2."""
|
||
stats = Stats(wisdom=10)
|
||
assert stats.resistance == 5 # 10 // 2
|
||
|
||
stats = Stats(wisdom=14)
|
||
assert stats.resistance == 7 # 14 // 2
|
||
|
||
stats = Stats(wisdom=9)
|
||
assert stats.resistance == 4 # 9 // 2
|
||
|
||
|
||
def test_stats_serialization():
|
||
"""Test to_dict() serialization."""
|
||
stats = Stats(
|
||
strength=15,
|
||
dexterity=12,
|
||
constitution=14,
|
||
intelligence=10,
|
||
wisdom=11,
|
||
charisma=8,
|
||
)
|
||
|
||
data = stats.to_dict()
|
||
|
||
assert data["strength"] == 15
|
||
assert data["dexterity"] == 12
|
||
assert data["constitution"] == 14
|
||
assert data["intelligence"] == 10
|
||
assert data["wisdom"] == 11
|
||
assert data["charisma"] == 8
|
||
|
||
|
||
def test_stats_deserialization():
|
||
"""Test from_dict() deserialization."""
|
||
data = {
|
||
"strength": 18,
|
||
"dexterity": 14,
|
||
"constitution": 16,
|
||
"intelligence": 12,
|
||
"wisdom": 10,
|
||
"charisma": 9,
|
||
}
|
||
|
||
stats = Stats.from_dict(data)
|
||
|
||
assert stats.strength == 18
|
||
assert stats.dexterity == 14
|
||
assert stats.constitution == 16
|
||
assert stats.intelligence == 12
|
||
assert stats.wisdom == 10
|
||
assert stats.charisma == 9
|
||
|
||
|
||
def test_stats_deserialization_with_missing_values():
|
||
"""Test from_dict() with missing values (should use defaults)."""
|
||
data = {
|
||
"strength": 15,
|
||
# Missing other stats
|
||
}
|
||
|
||
stats = Stats.from_dict(data)
|
||
|
||
assert stats.strength == 15
|
||
assert stats.dexterity == 10 # Default
|
||
assert stats.constitution == 10 # Default
|
||
assert stats.intelligence == 10 # Default
|
||
assert stats.wisdom == 10 # Default
|
||
assert stats.charisma == 10 # Default
|
||
|
||
|
||
def test_stats_round_trip_serialization():
|
||
"""Test that serialization and deserialization preserve data."""
|
||
original = Stats(
|
||
strength=20,
|
||
dexterity=15,
|
||
constitution=18,
|
||
intelligence=10,
|
||
wisdom=12,
|
||
charisma=14,
|
||
)
|
||
|
||
# Serialize then deserialize
|
||
data = original.to_dict()
|
||
restored = Stats.from_dict(data)
|
||
|
||
assert restored.strength == original.strength
|
||
assert restored.dexterity == original.dexterity
|
||
assert restored.constitution == original.constitution
|
||
assert restored.intelligence == original.intelligence
|
||
assert restored.wisdom == original.wisdom
|
||
assert restored.charisma == original.charisma
|
||
|
||
|
||
def test_stats_copy():
|
||
"""Test that copy() creates an independent copy."""
|
||
original = Stats(strength=15, dexterity=12, constitution=14)
|
||
copy = original.copy()
|
||
|
||
assert copy.strength == original.strength
|
||
assert copy.dexterity == original.dexterity
|
||
assert copy.constitution == original.constitution
|
||
|
||
# Modify copy
|
||
copy.strength = 20
|
||
|
||
# Original should be unchanged
|
||
assert original.strength == 15
|
||
assert copy.strength == 20
|
||
|
||
|
||
def test_stats_repr():
|
||
"""Test string representation."""
|
||
stats = Stats(strength=15, constitution=12, intelligence=10)
|
||
repr_str = repr(stats)
|
||
|
||
assert "STR=15" in repr_str
|
||
assert "CON=12" in repr_str
|
||
assert "INT=10" in repr_str
|
||
assert "HP=" in repr_str
|
||
assert "MP=" in repr_str
|
||
|
||
|
||
# =============================================================================
|
||
# LUK Computed Properties (Combat System Integration)
|
||
# =============================================================================
|
||
|
||
def test_crit_bonus_calculation():
|
||
"""Test crit bonus calculation: luck * 0.5%."""
|
||
stats = Stats(luck=8)
|
||
assert stats.crit_bonus == pytest.approx(0.04, abs=0.001) # 4%
|
||
|
||
stats = Stats(luck=12)
|
||
assert stats.crit_bonus == pytest.approx(0.06, abs=0.001) # 6%
|
||
|
||
stats = Stats(luck=0)
|
||
assert stats.crit_bonus == pytest.approx(0.0, abs=0.001) # 0%
|
||
|
||
|
||
def test_hit_bonus_calculation():
|
||
"""Test hit bonus (miss reduction): luck * 0.5%."""
|
||
stats = Stats(luck=8)
|
||
assert stats.hit_bonus == pytest.approx(0.04, abs=0.001) # 4%
|
||
|
||
stats = Stats(luck=12)
|
||
assert stats.hit_bonus == pytest.approx(0.06, abs=0.001) # 6%
|
||
|
||
stats = Stats(luck=20)
|
||
assert stats.hit_bonus == pytest.approx(0.10, abs=0.001) # 10%
|
||
|
||
|
||
def test_lucky_roll_chance_calculation():
|
||
"""Test lucky roll chance: 5% + (luck * 0.25%)."""
|
||
stats = Stats(luck=8)
|
||
# 5% + (8 * 0.25%) = 5% + 2% = 7%
|
||
assert stats.lucky_roll_chance == pytest.approx(0.07, abs=0.001)
|
||
|
||
stats = Stats(luck=12)
|
||
# 5% + (12 * 0.25%) = 5% + 3% = 8%
|
||
assert stats.lucky_roll_chance == pytest.approx(0.08, abs=0.001)
|
||
|
||
stats = Stats(luck=0)
|
||
# 5% + (0 * 0.25%) = 5%
|
||
assert stats.lucky_roll_chance == pytest.approx(0.05, abs=0.001)
|
||
|
||
|
||
def test_repr_includes_combat_bonuses():
|
||
"""Test that repr includes LUK-based combat bonuses."""
|
||
stats = Stats(luck=10)
|
||
repr_str = repr(stats)
|
||
|
||
assert "CRIT_BONUS=" in repr_str
|
||
assert "HIT_BONUS=" in repr_str
|
||
|
||
|
||
# =============================================================================
|
||
# Equipment Bonus Fields (Task 2.5)
|
||
# =============================================================================
|
||
|
||
def test_bonus_fields_default_to_zero():
|
||
"""Test that equipment bonus fields default to zero."""
|
||
stats = Stats()
|
||
|
||
assert stats.damage_bonus == 0
|
||
assert stats.defense_bonus == 0
|
||
assert stats.resistance_bonus == 0
|
||
|
||
|
||
def test_damage_property_with_no_bonus():
|
||
"""Test damage calculation: int(strength * 0.75) + damage_bonus with no bonus."""
|
||
stats = Stats(strength=10)
|
||
# int(10 * 0.75) = 7, no bonus
|
||
assert stats.damage == 7
|
||
|
||
stats = Stats(strength=14)
|
||
# int(14 * 0.75) = 10, no bonus
|
||
assert stats.damage == 10
|
||
|
||
|
||
def test_damage_property_with_bonus():
|
||
"""Test damage calculation includes damage_bonus from weapons."""
|
||
stats = Stats(strength=10, damage_bonus=15)
|
||
# int(10 * 0.75) + 15 = 7 + 15 = 22
|
||
assert stats.damage == 22
|
||
|
||
stats = Stats(strength=14, damage_bonus=8)
|
||
# int(14 * 0.75) + 8 = 10 + 8 = 18
|
||
assert stats.damage == 18
|
||
|
||
|
||
def test_defense_property_with_bonus():
|
||
"""Test defense calculation includes defense_bonus from armor."""
|
||
stats = Stats(constitution=10, defense_bonus=10)
|
||
# (10 // 2) + 10 = 5 + 10 = 15
|
||
assert stats.defense == 15
|
||
|
||
stats = Stats(constitution=20, defense_bonus=5)
|
||
# (20 // 2) + 5 = 10 + 5 = 15
|
||
assert stats.defense == 15
|
||
|
||
|
||
def test_resistance_property_with_bonus():
|
||
"""Test resistance calculation includes resistance_bonus from armor."""
|
||
stats = Stats(wisdom=10, resistance_bonus=8)
|
||
# (10 // 2) + 8 = 5 + 8 = 13
|
||
assert stats.resistance == 13
|
||
|
||
stats = Stats(wisdom=14, resistance_bonus=3)
|
||
# (14 // 2) + 3 = 7 + 3 = 10
|
||
assert stats.resistance == 10
|
||
|
||
|
||
def test_bonus_fields_serialization():
|
||
"""Test that bonus fields are included in to_dict()."""
|
||
stats = Stats(
|
||
strength=15,
|
||
damage_bonus=12,
|
||
defense_bonus=8,
|
||
resistance_bonus=5,
|
||
)
|
||
|
||
data = stats.to_dict()
|
||
|
||
assert data["damage_bonus"] == 12
|
||
assert data["defense_bonus"] == 8
|
||
assert data["resistance_bonus"] == 5
|
||
|
||
|
||
def test_bonus_fields_deserialization():
|
||
"""Test that bonus fields are restored from from_dict()."""
|
||
data = {
|
||
"strength": 15,
|
||
"damage_bonus": 12,
|
||
"defense_bonus": 8,
|
||
"resistance_bonus": 5,
|
||
}
|
||
|
||
stats = Stats.from_dict(data)
|
||
|
||
assert stats.damage_bonus == 12
|
||
assert stats.defense_bonus == 8
|
||
assert stats.resistance_bonus == 5
|
||
|
||
|
||
def test_bonus_fields_deserialization_defaults():
|
||
"""Test that missing bonus fields default to zero on deserialization."""
|
||
data = {
|
||
"strength": 15,
|
||
# No bonus fields
|
||
}
|
||
|
||
stats = Stats.from_dict(data)
|
||
|
||
assert stats.damage_bonus == 0
|
||
assert stats.defense_bonus == 0
|
||
assert stats.resistance_bonus == 0
|
||
|
||
|
||
def test_copy_includes_bonus_fields():
|
||
"""Test that copy() preserves bonus fields."""
|
||
original = Stats(
|
||
strength=15,
|
||
damage_bonus=10,
|
||
defense_bonus=8,
|
||
resistance_bonus=5,
|
||
)
|
||
copy = original.copy()
|
||
|
||
assert copy.damage_bonus == 10
|
||
assert copy.defense_bonus == 8
|
||
assert copy.resistance_bonus == 5
|
||
|
||
# Verify independence
|
||
copy.damage_bonus = 20
|
||
assert original.damage_bonus == 10
|
||
assert copy.damage_bonus == 20
|
||
|
||
|
||
def test_repr_includes_damage():
|
||
"""Test that repr includes the damage computed property."""
|
||
stats = Stats(strength=10, damage_bonus=15)
|
||
repr_str = repr(stats)
|
||
|
||
assert "DMG=" in repr_str
|