Files
Code_of_Conquest/api/tests/test_character.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

635 lines
19 KiB
Python

"""
Unit tests for Character dataclass.
Tests the critical get_effective_stats() method which combines all stat modifiers,
as well as inventory, equipment, experience, and serialization.
"""
import pytest
from app.models.character import Character
from app.models.stats import Stats
from app.models.items import Item
from app.models.effects import Effect
from app.models.skills import PlayerClass, SkillTree, SkillNode
from app.models.origins import Origin, StartingLocation, StartingBonus
from app.models.enums import ItemType, EffectType, StatType, DamageType
@pytest.fixture
def basic_player_class():
"""Create a basic player class for testing."""
base_stats = Stats(strength=12, dexterity=10, constitution=14, intelligence=8, wisdom=10, charisma=11)
# Create a simple skill tree
skill_tree = SkillTree(
tree_id="warrior_offense",
name="Warrior Offense",
description="Offensive combat skills",
nodes=[
SkillNode(
skill_id="power_strike",
name="Power Strike",
description="+5 Strength",
tier=1,
effects={"strength": 5},
),
],
)
return PlayerClass(
class_id="warrior",
name="Warrior",
description="Strong melee fighter",
base_stats=base_stats,
skill_trees=[skill_tree],
starting_equipment=["basic_sword"],
starting_abilities=["basic_attack"],
)
@pytest.fixture
def basic_origin():
"""Create a basic origin for testing."""
starting_location = StartingLocation(
id="test_location",
name="Test Village",
region="Test Region",
description="A simple test location"
)
starting_bonus = StartingBonus(
trait="Test Trait",
description="A test trait for testing",
effect="+1 to all stats"
)
return Origin(
id="test_origin",
name="Test Origin",
description="A test origin for character testing",
starting_location=starting_location,
narrative_hooks=["Test hook 1", "Test hook 2"],
starting_bonus=starting_bonus
)
@pytest.fixture
def basic_character(basic_player_class, basic_origin):
"""Create a basic character for testing."""
return Character(
character_id="char_001",
user_id="user_001",
name="Test Hero",
player_class=basic_player_class,
origin=basic_origin,
level=1,
experience=0,
base_stats=basic_player_class.base_stats.copy(),
)
def test_character_creation(basic_character):
"""Test creating a Character instance."""
assert basic_character.character_id == "char_001"
assert basic_character.user_id == "user_001"
assert basic_character.name == "Test Hero"
assert basic_character.level == 1
assert basic_character.experience == 0
assert basic_character.gold == 0
def test_get_effective_stats_base_only(basic_character):
"""Test get_effective_stats() with only base stats (no modifiers)."""
effective = basic_character.get_effective_stats()
# Should match base stats exactly
assert effective.strength == 12
assert effective.dexterity == 10
assert effective.constitution == 14
assert effective.intelligence == 8
assert effective.wisdom == 10
assert effective.charisma == 11
def test_get_effective_stats_with_equipment(basic_character):
"""Test get_effective_stats() with equipped items."""
# Create a weapon with +5 strength
weapon = Item(
item_id="iron_sword",
name="Iron Sword",
item_type=ItemType.WEAPON,
description="A sturdy iron sword",
stat_bonuses={"strength": 5},
damage=10,
)
basic_character.equipped["weapon"] = weapon
effective = basic_character.get_effective_stats()
# Strength should be base (12) + weapon (5) = 17
assert effective.strength == 17
assert effective.dexterity == 10 # Unchanged
def test_get_effective_stats_with_skill_bonuses(basic_character):
"""Test get_effective_stats() with skill tree bonuses."""
# Unlock the "power_strike" skill which gives +5 strength
basic_character.unlocked_skills.append("power_strike")
effective = basic_character.get_effective_stats()
# Strength should be base (12) + skill (5) = 17
assert effective.strength == 17
def test_get_effective_stats_with_all_modifiers(basic_character):
"""Test get_effective_stats() with equipment + skills + active effects."""
# Add equipment: +5 strength
weapon = Item(
item_id="iron_sword",
name="Iron Sword",
item_type=ItemType.WEAPON,
description="A sturdy iron sword",
stat_bonuses={"strength": 5},
damage=10,
)
basic_character.equipped["weapon"] = weapon
# Unlock skill: +5 strength
basic_character.unlocked_skills.append("power_strike")
# Add buff effect: +3 strength
buff = Effect(
effect_id="str_buff",
name="Strength Boost",
effect_type=EffectType.BUFF,
duration=3,
power=3,
stat_affected=StatType.STRENGTH,
)
effective = basic_character.get_effective_stats([buff])
# Total strength: 12 (base) + 5 (weapon) + 5 (skill) + 3 (buff) = 25
assert effective.strength == 25
def test_get_effective_stats_with_debuff(basic_character):
"""Test get_effective_stats() with a debuff."""
debuff = Effect(
effect_id="weakened",
name="Weakened",
effect_type=EffectType.DEBUFF,
duration=2,
power=5,
stat_affected=StatType.STRENGTH,
)
effective = basic_character.get_effective_stats([debuff])
# Strength: 12 (base) - 5 (debuff) = 7
assert effective.strength == 7
def test_get_effective_stats_debuff_minimum(basic_character):
"""Test that debuffs cannot reduce stats below 1."""
# Massive debuff
debuff = Effect(
effect_id="weakened",
name="Weakened",
effect_type=EffectType.DEBUFF,
duration=2,
power=20, # More than base strength
stat_affected=StatType.STRENGTH,
)
effective = basic_character.get_effective_stats([debuff])
# Should be clamped at 1, not 0 or negative
assert effective.strength == 1
def test_inventory_management(basic_character):
"""Test adding and removing items from inventory."""
item = Item(
item_id="potion",
name="Health Potion",
item_type=ItemType.CONSUMABLE,
description="Restores 50 HP",
value=25,
)
# Add item
basic_character.add_item(item)
assert len(basic_character.inventory) == 1
assert basic_character.inventory[0].item_id == "potion"
# Remove item
removed = basic_character.remove_item("potion")
assert removed.item_id == "potion"
assert len(basic_character.inventory) == 0
def test_equip_and_unequip(basic_character):
"""Test equipping and unequipping items."""
weapon = Item(
item_id="iron_sword",
name="Iron Sword",
item_type=ItemType.WEAPON,
description="A sturdy sword",
stat_bonuses={"strength": 5},
damage=10,
)
# Add to inventory first
basic_character.add_item(weapon)
assert len(basic_character.inventory) == 1
# Equip weapon
basic_character.equip_item(weapon, "weapon")
assert "weapon" in basic_character.equipped
assert basic_character.equipped["weapon"].item_id == "iron_sword"
assert len(basic_character.inventory) == 0 # Removed from inventory
# Unequip weapon
unequipped = basic_character.unequip_item("weapon")
assert unequipped.item_id == "iron_sword"
assert "weapon" not in basic_character.equipped
assert len(basic_character.inventory) == 1 # Back in inventory
def test_equip_replaces_existing(basic_character):
"""Test that equipping a new item replaces the old one."""
weapon1 = Item(
item_id="iron_sword",
name="Iron Sword",
item_type=ItemType.WEAPON,
description="A sturdy sword",
damage=10,
)
weapon2 = Item(
item_id="steel_sword",
name="Steel Sword",
item_type=ItemType.WEAPON,
description="A better sword",
damage=15,
)
# Equip first weapon
basic_character.add_item(weapon1)
basic_character.equip_item(weapon1, "weapon")
# Equip second weapon
basic_character.add_item(weapon2)
previous = basic_character.equip_item(weapon2, "weapon")
assert previous.item_id == "iron_sword" # Old weapon returned
assert basic_character.equipped["weapon"].item_id == "steel_sword"
assert len(basic_character.inventory) == 1 # Old weapon back in inventory
def test_gold_management(basic_character):
"""Test adding and removing gold."""
assert basic_character.gold == 0
# Add gold
basic_character.add_gold(100)
assert basic_character.gold == 100
# Remove gold
success = basic_character.remove_gold(50)
assert success == True
assert basic_character.gold == 50
# Try to remove more than available
success = basic_character.remove_gold(100)
assert success == False
assert basic_character.gold == 50 # Unchanged
def test_can_afford(basic_character):
"""Test can_afford() method."""
basic_character.gold = 100
assert basic_character.can_afford(50) == True
assert basic_character.can_afford(100) == True
assert basic_character.can_afford(101) == False
def test_add_experience_no_level_up(basic_character):
"""Test adding experience without leveling up."""
leveled_up = basic_character.add_experience(50)
assert leveled_up == False
assert basic_character.level == 1
assert basic_character.experience == 50
def test_add_experience_with_level_up(basic_character):
"""Test adding enough experience to level up."""
# Level 1 requires 100 XP for level 2
leveled_up = basic_character.add_experience(100)
assert leveled_up == True
assert basic_character.level == 2
assert basic_character.experience == 0 # Reset
def test_add_experience_with_overflow(basic_character):
"""Test leveling up with overflow experience."""
# Level 1 requires 100 XP, give 150
leveled_up = basic_character.add_experience(150)
assert leveled_up == True
assert basic_character.level == 2
assert basic_character.experience == 50 # Overflow
def test_xp_calculation(basic_origin):
"""Test XP required for each level."""
char = Character(
character_id="test",
user_id="user",
name="Test",
player_class=PlayerClass(
class_id="test",
name="Test",
description="Test",
base_stats=Stats(),
),
origin=basic_origin,
)
# Formula: 100 * (level ^ 1.5)
assert char._calculate_xp_for_next_level() == 100 # Level 1→2
char.level = 2
assert char._calculate_xp_for_next_level() == 282 # Level 2→3
char.level = 3
assert char._calculate_xp_for_next_level() == 519 # Level 3→4
def test_get_unlocked_abilities(basic_character):
"""Test getting abilities from class + unlocked skills."""
# Should have starting abilities
abilities = basic_character.get_unlocked_abilities()
assert "basic_attack" in abilities
# TODO: When skills unlock abilities, test that here
def test_character_serialization(basic_character):
"""Test character to_dict() serialization."""
# Add some data
basic_character.gold = 500
basic_character.level = 3
basic_character.experience = 100
data = basic_character.to_dict()
assert data["character_id"] == "char_001"
assert data["user_id"] == "user_001"
assert data["name"] == "Test Hero"
assert data["level"] == 3
assert data["experience"] == 100
assert data["gold"] == 500
def test_character_deserialization(basic_player_class, basic_origin):
"""Test character from_dict() deserialization."""
data = {
"character_id": "char_002",
"user_id": "user_002",
"name": "Restored Hero",
"player_class": basic_player_class.to_dict(),
"origin": basic_origin.to_dict(),
"level": 5,
"experience": 200,
"base_stats": Stats(strength=15).to_dict(),
"unlocked_skills": ["power_strike"],
"inventory": [],
"equipped": {},
"gold": 1000,
"active_quests": ["quest_1"],
"discovered_locations": ["town_1"],
}
char = Character.from_dict(data)
assert char.character_id == "char_002"
assert char.name == "Restored Hero"
assert char.level == 5
assert char.gold == 1000
assert "power_strike" in char.unlocked_skills
def test_character_round_trip_serialization(basic_character):
"""Test that serialization and deserialization preserve all data."""
# Add complex state
basic_character.gold = 500
basic_character.level = 3
basic_character.unlocked_skills = ["power_strike"]
weapon = Item(
item_id="sword",
name="Sword",
item_type=ItemType.WEAPON,
description="A sword",
damage=10,
)
basic_character.equipped["weapon"] = weapon
# Serialize and deserialize
data = basic_character.to_dict()
restored = Character.from_dict(data)
assert restored.character_id == basic_character.character_id
assert restored.name == basic_character.name
assert restored.level == basic_character.level
assert restored.gold == basic_character.gold
assert restored.unlocked_skills == basic_character.unlocked_skills
assert "weapon" in restored.equipped
assert restored.equipped["weapon"].item_id == "sword"
# =============================================================================
# Equipment Combat Bonuses (Task 2.5)
# =============================================================================
def test_get_effective_stats_weapon_damage_bonus(basic_character):
"""Test that weapon damage is added to effective stats damage_bonus."""
# Create weapon with damage
weapon = Item(
item_id="iron_sword",
name="Iron Sword",
item_type=ItemType.WEAPON,
description="A sturdy iron sword",
damage=15, # 15 damage
)
basic_character.equipped["weapon"] = weapon
effective = basic_character.get_effective_stats()
# Base strength is 12, so base damage = int(12 * 0.75) = 9
# Weapon damage = 15
# Total damage property = 9 + 15 = 24
assert effective.damage_bonus == 15
assert effective.damage == 24 # int(12 * 0.75) + 15
def test_get_effective_stats_armor_defense_bonus(basic_character):
"""Test that armor defense is added to effective stats defense_bonus."""
# Create armor with defense
armor = Item(
item_id="iron_chestplate",
name="Iron Chestplate",
item_type=ItemType.ARMOR,
description="A sturdy iron chestplate",
defense=10,
resistance=0,
)
basic_character.equipped["chest"] = armor
effective = basic_character.get_effective_stats()
# Base constitution is 14, so base defense = 14 // 2 = 7
# Armor defense = 10
# Total defense property = 7 + 10 = 17
assert effective.defense_bonus == 10
assert effective.defense == 17 # (14 // 2) + 10
def test_get_effective_stats_armor_resistance_bonus(basic_character):
"""Test that armor resistance is added to effective stats resistance_bonus."""
# Create armor with resistance
robe = Item(
item_id="magic_robe",
name="Magic Robe",
item_type=ItemType.ARMOR,
description="An enchanted robe",
defense=2,
resistance=8,
)
basic_character.equipped["chest"] = robe
effective = basic_character.get_effective_stats()
# Base wisdom is 10, so base resistance = 10 // 2 = 5
# Armor resistance = 8
# Total resistance property = 5 + 8 = 13
assert effective.resistance_bonus == 8
assert effective.resistance == 13 # (10 // 2) + 8
def test_get_effective_stats_multiple_armor_pieces(basic_character):
"""Test that multiple armor pieces stack their bonuses."""
# Create multiple armor pieces
helmet = Item(
item_id="iron_helmet",
name="Iron Helmet",
item_type=ItemType.ARMOR,
description="Protects your head",
defense=5,
resistance=2,
)
chestplate = Item(
item_id="iron_chestplate",
name="Iron Chestplate",
item_type=ItemType.ARMOR,
description="Protects your torso",
defense=10,
resistance=3,
)
boots = Item(
item_id="iron_boots",
name="Iron Boots",
item_type=ItemType.ARMOR,
description="Protects your feet",
defense=3,
resistance=1,
)
basic_character.equipped["helmet"] = helmet
basic_character.equipped["chest"] = chestplate
basic_character.equipped["boots"] = boots
effective = basic_character.get_effective_stats()
# Total defense bonus = 5 + 10 + 3 = 18
# Total resistance bonus = 2 + 3 + 1 = 6
assert effective.defense_bonus == 18
assert effective.resistance_bonus == 6
# Base constitution is 14: base defense = 7
# Base wisdom is 10: base resistance = 5
assert effective.defense == 25 # 7 + 18
assert effective.resistance == 11 # 5 + 6
def test_get_effective_stats_weapon_and_armor_combined(basic_character):
"""Test that weapon damage and armor defense/resistance work together."""
# Create weapon
weapon = Item(
item_id="flaming_sword",
name="Flaming Sword",
item_type=ItemType.WEAPON,
description="A sword wreathed in flame",
damage=18,
stat_bonuses={"strength": 3}, # Also has stat bonus
)
# Create armor
armor = Item(
item_id="dragon_armor",
name="Dragon Armor",
item_type=ItemType.ARMOR,
description="Forged from dragon scales",
defense=15,
resistance=10,
stat_bonuses={"constitution": 2}, # Also has stat bonus
)
basic_character.equipped["weapon"] = weapon
basic_character.equipped["chest"] = armor
effective = basic_character.get_effective_stats()
# Weapon: damage=18, +3 STR
# Armor: defense=15, resistance=10, +2 CON
# Base STR=12 -> 12+3=15, damage = int(15 * 0.75) + 18 = 11 + 18 = 29
assert effective.strength == 15
assert effective.damage_bonus == 18
assert effective.damage == 29
# Base CON=14 -> 14+2=16, defense = (16//2) + 15 = 8 + 15 = 23
assert effective.constitution == 16
assert effective.defense_bonus == 15
assert effective.defense == 23
# Base WIS=10, resistance = (10//2) + 10 = 5 + 10 = 15
assert effective.resistance_bonus == 10
assert effective.resistance == 15
def test_get_effective_stats_no_equipment_bonuses(basic_character):
"""Test that bonus fields are zero when no equipment is equipped."""
effective = basic_character.get_effective_stats()
assert effective.damage_bonus == 0
assert effective.defense_bonus == 0
assert effective.resistance_bonus == 0
# Damage/defense/resistance should just be base stat derived values
# Base STR=12, damage = int(12 * 0.75) = 9
assert effective.damage == 9
# Base CON=14, defense = 14 // 2 = 7
assert effective.defense == 7
# Base WIS=10, resistance = 10 // 2 = 5
assert effective.resistance == 5