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

@@ -109,6 +109,7 @@ class BaseItemLoader:
# Set defaults for missing optional fields
template_data.setdefault("description", "")
template_data.setdefault("base_damage", 0)
template_data.setdefault("base_spell_power", 0)
template_data.setdefault("base_defense", 0)
template_data.setdefault("base_resistance", 0)
template_data.setdefault("base_value", 10)

View File

@@ -571,17 +571,26 @@ class CombatService:
message="Invalid or dead target"
)
# Get attacker's weapon damage (or base damage for enemies)
weapon_damage = self._get_weapon_damage(attacker)
crit_chance = self._get_crit_chance(attacker)
# Calculate damage using DamageCalculator
damage_result = DamageCalculator.calculate_physical_damage(
attacker_stats=attacker.stats,
defender_stats=target.stats,
weapon_damage=weapon_damage,
weapon_crit_chance=crit_chance,
)
# Check if this is an elemental weapon attack
if attacker.elemental_ratio > 0.0 and attacker.elemental_damage_type:
# Elemental weapon: split damage between physical and elemental
damage_result = DamageCalculator.calculate_elemental_weapon_damage(
attacker_stats=attacker.stats,
defender_stats=target.stats,
weapon_crit_chance=attacker.weapon_crit_chance,
weapon_crit_multiplier=attacker.weapon_crit_multiplier,
physical_ratio=attacker.physical_ratio,
elemental_ratio=attacker.elemental_ratio,
elemental_type=attacker.elemental_damage_type,
)
else:
# Normal physical attack
damage_result = DamageCalculator.calculate_physical_damage(
attacker_stats=attacker.stats,
defender_stats=target.stats,
weapon_crit_chance=attacker.weapon_crit_chance,
weapon_crit_multiplier=attacker.weapon_crit_multiplier,
)
# Add target_id to result for tracking
damage_result.target_id = target.combatant_id
@@ -970,6 +979,25 @@ class CombatService:
abilities = ["basic_attack"] # All characters have basic attack
abilities.extend(character.unlocked_skills)
# Extract weapon properties from equipped weapon
weapon = character.equipped.get("weapon")
weapon_crit_chance = 0.05
weapon_crit_multiplier = 2.0
weapon_damage_type = DamageType.PHYSICAL
elemental_damage_type = None
physical_ratio = 1.0
elemental_ratio = 0.0
if weapon and weapon.is_weapon():
weapon_crit_chance = weapon.crit_chance
weapon_crit_multiplier = weapon.crit_multiplier
weapon_damage_type = weapon.damage_type or DamageType.PHYSICAL
if weapon.is_elemental_weapon():
elemental_damage_type = weapon.elemental_damage_type
physical_ratio = weapon.physical_ratio
elemental_ratio = weapon.elemental_ratio
return Combatant(
combatant_id=character.character_id,
name=character.name,
@@ -980,6 +1008,12 @@ class CombatService:
max_mp=effective_stats.mana_points,
stats=effective_stats,
abilities=abilities,
weapon_crit_chance=weapon_crit_chance,
weapon_crit_multiplier=weapon_crit_multiplier,
weapon_damage_type=weapon_damage_type,
elemental_damage_type=elemental_damage_type,
physical_ratio=physical_ratio,
elemental_ratio=elemental_ratio,
)
def _create_combatant_from_enemy(
@@ -996,7 +1030,9 @@ class CombatService:
if instance_index > 0:
name = f"{template.name} #{instance_index + 1}"
stats = template.base_stats
# Copy stats and populate damage_bonus with base_damage
stats = template.base_stats.copy()
stats.damage_bonus = template.base_damage
return Combatant(
combatant_id=combatant_id,
@@ -1008,23 +1044,15 @@ class CombatService:
max_mp=stats.mana_points,
stats=stats,
abilities=template.abilities.copy(),
weapon_crit_chance=template.crit_chance,
weapon_crit_multiplier=2.0,
weapon_damage_type=DamageType.PHYSICAL,
)
def _get_weapon_damage(self, combatant: Combatant) -> int:
"""Get weapon damage for a combatant."""
# For enemies, use base_damage from template
if not combatant.is_player:
# Base damage stored in combatant data or default
return 8 # Default enemy damage
# For players, would check equipped weapon
# TODO: Check character's equipped weapon
return 5 # Default unarmed damage
def _get_crit_chance(self, combatant: Combatant) -> float:
"""Get critical hit chance for a combatant."""
# Base 5% + LUK bonus
return 0.05 + combatant.stats.crit_bonus
# Weapon crit chance + LUK bonus
return combatant.weapon_crit_chance + combatant.stats.crit_bonus
def _get_default_target(
self,

View File

@@ -6,9 +6,11 @@ Handles physical, magical, and elemental damage with LUK stat integration
for variance, critical hits, and accuracy.
Formulas:
Physical: (Weapon_Base + STR * 0.75) * Variance * Crit_Mult - DEF
Magical: (Ability_Base + INT * 0.75) * Variance * Crit_Mult - RES
Elemental: Split between physical and magical components
Physical: (effective_stats.damage + ability_power) * Variance * Crit_Mult - DEF
where effective_stats.damage = int(STR * 0.75) + damage_bonus (from weapon)
Magical: (effective_stats.spell_power + ability_power) * Variance * Crit_Mult - RES
where effective_stats.spell_power = int(INT * 0.75) + spell_power_bonus (from staff/wand)
Elemental: Split between physical and magical components using ratios
LUK Integration:
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
@@ -275,7 +277,6 @@ class DamageCalculator:
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int = 0,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
ability_base_power: int = 0,
@@ -286,13 +287,13 @@ class DamageCalculator:
Calculate physical damage for a melee/ranged attack.
Formula:
Base = Weapon_Base + Ability_Power + (STR * 0.75)
Base = attacker_stats.damage + ability_base_power
where attacker_stats.damage = int(STR * 0.75) + damage_bonus
Damage = Base * Variance * Crit_Mult - DEF
Args:
attacker_stats: Attacker's Stats (STR, LUK used)
attacker_stats: Attacker's Stats (includes weapon damage via damage property)
defender_stats: Defender's Stats (DEX, CON used)
weapon_damage: Base damage from equipped weapon
weapon_crit_chance: Crit chance from weapon (default 5%)
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
ability_base_power: Additional base power from ability
@@ -317,9 +318,8 @@ class DamageCalculator:
return result
# Step 2: Calculate base damage
# Formula: weapon + ability + (STR * scaling_factor)
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR
base_damage = weapon_damage + ability_base_power + str_bonus
# attacker_stats.damage already includes: int(STR * 0.75) + damage_bonus (weapon)
base_damage = attacker_stats.damage + ability_base_power
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
@@ -371,11 +371,12 @@ class DamageCalculator:
LUK benefits all classes equally.
Formula:
Base = Ability_Power + (INT * 0.75)
Base = attacker_stats.spell_power + ability_base_power
where attacker_stats.spell_power = int(INT * 0.75) + spell_power_bonus
Damage = Base * Variance * Crit_Mult - RES
Args:
attacker_stats: Attacker's Stats (INT, LUK used)
attacker_stats: Attacker's Stats (includes staff/wand spell_power via spell_power property)
defender_stats: Defender's Stats (DEX, WIS used)
ability_base_power: Base power of the spell
damage_type: Type of magical damage (fire, ice, etc.)
@@ -402,9 +403,8 @@ class DamageCalculator:
return result
# Step 2: Calculate base damage
# Formula: ability + (INT * scaling_factor)
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR
base_damage = ability_base_power + int_bonus
# attacker_stats.spell_power already includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
base_damage = attacker_stats.spell_power + ability_base_power
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
@@ -442,7 +442,6 @@ class DamageCalculator:
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int,
weapon_crit_chance: float,
weapon_crit_multiplier: float,
physical_ratio: float,
@@ -459,8 +458,8 @@ class DamageCalculator:
calculated separately against DEF and RES respectively.
Formula:
Physical = (Weapon * PHYS_RATIO + STR * 0.75 * PHYS_RATIO) - DEF
Elemental = (Weapon * ELEM_RATIO + INT * 0.75 * ELEM_RATIO) - RES
Physical = (attacker_stats.damage + ability_power) * PHYS_RATIO - DEF
Elemental = (attacker_stats.spell_power + ability_power) * ELEM_RATIO - RES
Total = Physical + Elemental
Recommended Split Ratios:
@@ -470,9 +469,8 @@ class DamageCalculator:
- Lightning Spear: 50% / 50%
Args:
attacker_stats: Attacker's Stats
attacker_stats: Attacker's Stats (damage and spell_power include equipment)
defender_stats: Defender's Stats
weapon_damage: Base weapon damage
weapon_crit_chance: Crit chance from weapon
weapon_crit_multiplier: Crit damage multiplier
physical_ratio: Portion of damage that is physical (0.0-1.0)
@@ -516,17 +514,15 @@ class DamageCalculator:
crit_mult = weapon_crit_multiplier if is_crit else 1.0
# Step 3: Calculate physical component
# Physical uses STR scaling
phys_base = (weapon_damage + ability_base_power) * physical_ratio
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR * physical_ratio
phys_damage = (phys_base + str_bonus) * variance * crit_mult
# attacker_stats.damage includes: int(STR * 0.75) + damage_bonus (weapon)
phys_base = (attacker_stats.damage + ability_base_power) * physical_ratio
phys_damage = phys_base * variance * crit_mult
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
# Step 4: Calculate elemental component
# Elemental uses INT scaling
elem_base = (weapon_damage + ability_base_power) * elemental_ratio
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR * elemental_ratio
elem_damage = (elem_base + int_bonus) * variance * crit_mult
# attacker_stats.spell_power includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
elem_base = (attacker_stats.spell_power + ability_base_power) * elemental_ratio
elem_damage = elem_base * variance * crit_mult
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
# Step 5: Combine results

View File

@@ -317,6 +317,7 @@ class ItemGenerator:
# Base values from template
damage = base_template.base_damage + combined_stats["damage_bonus"]
spell_power = base_template.base_spell_power # Magical weapon damage
defense = base_template.base_defense + combined_stats["defense_bonus"]
resistance = base_template.base_resistance + combined_stats["resistance_bonus"]
crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"]
@@ -353,6 +354,7 @@ class ItemGenerator:
stat_bonuses=combined_stats["stat_bonuses"],
effects_on_use=[], # Not a consumable
damage=damage,
spell_power=spell_power, # Magical weapon damage bonus
damage_type=DamageType(base_template.damage_type) if damage > 0 else None,
crit_chance=crit_chance,
crit_multiplier=crit_multiplier,