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

@@ -146,14 +146,59 @@ weapons:
name: "Wizard Staff"
item_type: "weapon"
description: "A staff attuned to magical energy"
base_damage: 8
base_damage: 4
base_spell_power: 12
base_value: 45
damage_type: "physical"
damage_type: "arcane"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 3
drop_weight: 0.8
arcane_staff:
template_id: "arcane_staff"
name: "Arcane Staff"
item_type: "weapon"
description: "A powerful staff pulsing with arcane power"
base_damage: 6
base_spell_power: 18
base_value: 90
damage_type: "arcane"
crit_chance: 0.06
crit_multiplier: 2.0
required_level: 5
drop_weight: 0.6
min_rarity: "uncommon"
# ==================== WANDS ====================
wand:
template_id: "wand"
name: "Wand"
item_type: "weapon"
description: "A simple magical focus"
base_damage: 2
base_spell_power: 8
base_value: 30
damage_type: "arcane"
crit_chance: 0.06
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.0
crystal_wand:
template_id: "crystal_wand"
name: "Crystal Wand"
item_type: "weapon"
description: "A wand topped with a magical crystal"
base_damage: 3
base_spell_power: 14
base_value: 60
damage_type: "arcane"
crit_chance: 0.07
crit_multiplier: 2.2
required_level: 4
drop_weight: 0.8
# ==================== RANGED ====================
shortbow:
template_id: "shortbow"

View File

@@ -207,6 +207,7 @@ class BaseItemTemplate:
# Base stats
base_damage: int = 0
base_spell_power: int = 0 # For magical weapons (staves, wands)
base_defense: int = 0
base_resistance: int = 0
base_value: int = 10
@@ -276,6 +277,7 @@ class BaseItemTemplate:
item_type=data["item_type"],
description=data.get("description", ""),
base_damage=data.get("base_damage", 0),
base_spell_power=data.get("base_spell_power", 0),
base_defense=data.get("base_defense", 0),
base_resistance=data.get("base_resistance", 0),
base_value=data.get("base_value", 10),

View File

@@ -13,7 +13,7 @@ from app.models.stats import Stats
from app.models.items import Item
from app.models.skills import PlayerClass, SkillNode
from app.models.effects import Effect
from app.models.enums import EffectType, StatType
from app.models.enums import EffectType, StatType, ItemType
from app.models.origins import Origin
@@ -92,7 +92,11 @@ class Character:
This is the CRITICAL METHOD that combines:
1. Base stats (from character)
2. Equipment bonuses (from equipped items)
2. Equipment bonuses (from equipped items):
- stat_bonuses dict applied to corresponding stats
- Weapon damage added to damage_bonus
- Weapon spell_power added to spell_power_bonus
- Armor defense/resistance added to defense_bonus/resistance_bonus
3. Skill tree bonuses (from unlocked skills)
4. Active effect modifiers (buffs/debuffs)
@@ -100,18 +104,30 @@ class Character:
active_effects: Currently active effects on this character (from combat)
Returns:
Stats instance with all modifiers applied
Stats instance with all modifiers applied (including computed
damage, defense, resistance properties that incorporate bonuses)
"""
# Start with a copy of base stats
effective = self.base_stats.copy()
# Apply equipment bonuses
for item in self.equipped.values():
# Apply stat bonuses from item (e.g., +3 strength)
for stat_name, bonus in item.stat_bonuses.items():
if hasattr(effective, stat_name):
current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus)
# Add weapon damage and spell_power to bonus fields
if item.item_type == ItemType.WEAPON:
effective.damage_bonus += item.damage
effective.spell_power_bonus += item.spell_power
# Add armor defense and resistance to bonus fields
if item.item_type == ItemType.ARMOR:
effective.defense_bonus += item.defense
effective.resistance_bonus += item.resistance
# Apply skill tree bonuses
skill_bonuses = self._get_skill_bonuses()
for stat_name, bonus in skill_bonuses.items():

View File

@@ -12,7 +12,7 @@ import random
from app.models.stats import Stats
from app.models.effects import Effect
from app.models.abilities import Ability
from app.models.enums import CombatStatus, EffectType
from app.models.enums import CombatStatus, EffectType, DamageType
@dataclass
@@ -36,6 +36,12 @@ class Combatant:
abilities: Available abilities for this combatant
cooldowns: Map of ability_id to turns remaining
initiative: Turn order value (rolled at combat start)
weapon_crit_chance: Critical hit chance from equipped weapon
weapon_crit_multiplier: Critical hit damage multiplier
weapon_damage_type: Primary damage type of weapon
elemental_damage_type: Secondary damage type for elemental weapons
physical_ratio: Portion of damage that is physical (0.0-1.0)
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
"""
combatant_id: str
@@ -51,6 +57,16 @@ class Combatant:
cooldowns: Dict[str, int] = field(default_factory=dict)
initiative: int = 0
# Weapon properties (for combat calculations)
weapon_crit_chance: float = 0.05
weapon_crit_multiplier: float = 2.0
weapon_damage_type: Optional[DamageType] = None
# Elemental weapon properties (for split damage)
elemental_damage_type: Optional[DamageType] = None
physical_ratio: float = 1.0
elemental_ratio: float = 0.0
def is_alive(self) -> bool:
"""Check if combatant is still alive."""
return self.current_hp > 0
@@ -228,6 +244,12 @@ class Combatant:
"abilities": self.abilities,
"cooldowns": self.cooldowns,
"initiative": self.initiative,
"weapon_crit_chance": self.weapon_crit_chance,
"weapon_crit_multiplier": self.weapon_crit_multiplier,
"weapon_damage_type": self.weapon_damage_type.value if self.weapon_damage_type else None,
"elemental_damage_type": self.elemental_damage_type.value if self.elemental_damage_type else None,
"physical_ratio": self.physical_ratio,
"elemental_ratio": self.elemental_ratio,
}
@classmethod
@@ -236,6 +258,15 @@ class Combatant:
stats = Stats.from_dict(data["stats"])
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
# Parse damage types
weapon_damage_type = None
if data.get("weapon_damage_type"):
weapon_damage_type = DamageType(data["weapon_damage_type"])
elemental_damage_type = None
if data.get("elemental_damage_type"):
elemental_damage_type = DamageType(data["elemental_damage_type"])
return cls(
combatant_id=data["combatant_id"],
name=data["name"],
@@ -249,6 +280,12 @@ class Combatant:
abilities=data.get("abilities", []),
cooldowns=data.get("cooldowns", {}),
initiative=data.get("initiative", 0),
weapon_crit_chance=data.get("weapon_crit_chance", 0.05),
weapon_crit_multiplier=data.get("weapon_crit_multiplier", 2.0),
weapon_damage_type=weapon_damage_type,
elemental_damage_type=elemental_damage_type,
physical_ratio=data.get("physical_ratio", 1.0),
elemental_ratio=data.get("elemental_ratio", 0.0),
)

View File

@@ -29,6 +29,7 @@ class DamageType(Enum):
HOLY = "holy" # Holy/divine damage
SHADOW = "shadow" # Dark/shadow magic damage
POISON = "poison" # Poison damage (usually DoT)
ARCANE = "arcane" # Pure magical damage (staves, wands)
class ItemType(Enum):

View File

@@ -33,7 +33,8 @@ class Item:
effects_on_use: Effects applied when consumed (consumables only)
Weapon-specific attributes:
damage: Base weapon damage
damage: Base weapon damage (physical/melee/ranged)
spell_power: Spell power for staves/wands (boosts spell damage)
damage_type: Type of damage (physical, fire, etc.)
crit_chance: Probability of critical hit (0.0 to 1.0)
crit_multiplier: Damage multiplier on critical hit
@@ -62,7 +63,8 @@ class Item:
effects_on_use: List[Effect] = field(default_factory=list)
# Weapon-specific
damage: int = 0
damage: int = 0 # Physical damage for melee/ranged weapons
spell_power: int = 0 # Spell power for staves/wands (boosts spell damage)
damage_type: Optional[DamageType] = None
crit_chance: float = 0.05 # 5% default critical hit chance
crit_multiplier: float = 2.0 # 2x damage on critical hit
@@ -136,6 +138,18 @@ class Item:
self.elemental_damage_type is not None
)
def is_magical_weapon(self) -> bool:
"""
Check if this weapon is a spell-casting weapon (staff, wand, tome).
Magical weapons provide spell_power which boosts spell damage,
rather than physical damage for melee/ranged attacks.
Returns:
True if weapon has spell_power (staves, wands, etc.)
"""
return self.is_weapon() and self.spell_power > 0
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
"""
Check if a character can equip this item.

View File

@@ -22,12 +22,18 @@ class Stats:
wisdom: Perception and insight, affects magical resistance
charisma: Social influence, affects NPC interactions
luck: Fortune and fate, affects critical hits, loot, and random outcomes
damage_bonus: Flat damage bonus from equipped weapons (default 0)
spell_power_bonus: Flat spell power bonus from staves/wands (default 0)
defense_bonus: Flat defense bonus from equipped armor (default 0)
resistance_bonus: Flat resistance bonus from equipped armor (default 0)
Computed Properties:
hit_points: Maximum HP = 10 + (constitution × 2)
mana_points: Maximum MP = 10 + (intelligence × 2)
defense: Physical defense = constitution // 2
resistance: Magical resistance = wisdom // 2
damage: Physical damage = int(strength × 0.75) + damage_bonus
spell_power: Spell power = int(intelligence × 0.75) + spell_power_bonus
defense: Physical defense = (constitution // 2) + defense_bonus
resistance: Magical resistance = (wisdom // 2) + resistance_bonus
"""
strength: int = 10
@@ -38,6 +44,12 @@ class Stats:
charisma: int = 10
luck: int = 8
# Equipment bonus fields (populated by get_effective_stats())
damage_bonus: int = 0 # From weapons (physical damage)
spell_power_bonus: int = 0 # From staves/wands (magical damage)
defense_bonus: int = 0 # From armor
resistance_bonus: int = 0 # From armor
@property
def hit_points(self) -> int:
"""
@@ -62,29 +74,65 @@ class Stats:
"""
return 10 + (self.intelligence * 2)
@property
def damage(self) -> int:
"""
Calculate total physical damage from strength and equipment.
Formula: int(strength * 0.75) + damage_bonus
The damage_bonus comes from equipped weapons and is populated
by Character.get_effective_stats().
Returns:
Total physical damage value
"""
return int(self.strength * 0.75) + self.damage_bonus
@property
def spell_power(self) -> int:
"""
Calculate spell power from intelligence and equipment.
Formula: int(intelligence * 0.75) + spell_power_bonus
The spell_power_bonus comes from equipped staves/wands and is
populated by Character.get_effective_stats().
Returns:
Total spell power value
"""
return int(self.intelligence * 0.75) + self.spell_power_bonus
@property
def defense(self) -> int:
"""
Calculate physical defense from constitution.
Calculate physical defense from constitution and equipment.
Formula: constitution // 2
Formula: (constitution // 2) + defense_bonus
The defense_bonus comes from equipped armor and is populated
by Character.get_effective_stats().
Returns:
Physical defense value (damage reduction)
"""
return self.constitution // 2
return (self.constitution // 2) + self.defense_bonus
@property
def resistance(self) -> int:
"""
Calculate magical resistance from wisdom.
Calculate magical resistance from wisdom and equipment.
Formula: wisdom // 2
Formula: (wisdom // 2) + resistance_bonus
The resistance_bonus comes from equipped armor and is populated
by Character.get_effective_stats().
Returns:
Magical resistance value (spell damage reduction)
"""
return self.wisdom // 2
return (self.wisdom // 2) + self.resistance_bonus
@property
def crit_bonus(self) -> float:
@@ -171,6 +219,10 @@ class Stats:
wisdom=data.get("wisdom", 10),
charisma=data.get("charisma", 10),
luck=data.get("luck", 8),
damage_bonus=data.get("damage_bonus", 0),
spell_power_bonus=data.get("spell_power_bonus", 0),
defense_bonus=data.get("defense_bonus", 0),
resistance_bonus=data.get("resistance_bonus", 0),
)
def copy(self) -> 'Stats':
@@ -188,6 +240,10 @@ class Stats:
wisdom=self.wisdom,
charisma=self.charisma,
luck=self.luck,
damage_bonus=self.damage_bonus,
spell_power_bonus=self.spell_power_bonus,
defense_bonus=self.defense_bonus,
resistance_bonus=self.resistance_bonus,
)
def __repr__(self) -> str:
@@ -197,6 +253,7 @@ class Stats:
f"CON={self.constitution}, INT={self.intelligence}, "
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
f"HP={self.hit_points}, MP={self.mana_points}, "
f"DMG={self.damage}, SP={self.spell_power}, "
f"DEF={self.defense}, RES={self.resistance}, "
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
)

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,

View File

@@ -452,3 +452,183 @@ def test_character_round_trip_serialization(basic_character):
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

View File

@@ -55,6 +55,7 @@ def mock_character(mock_stats):
char.experience = 1000
char.gold = 100
char.unlocked_skills = ["power_strike"]
char.equipped = {} # No equipment by default
char.get_effective_stats = Mock(return_value=mock_stats)
return char

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,

View File

@@ -248,3 +248,134 @@ def test_repr_includes_combat_bonuses():
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

View File

@@ -1,9 +1,9 @@
# Phase 4: Combat & Progression Systems - Implementation Plan
**Status:** In Progress - Week 2 In Progress
**Status:** In Progress - Week 2 Complete, Week 3 Next
**Timeline:** 4-5 weeks
**Last Updated:** November 26, 2025
**Document Version:** 1.1
**Document Version:** 1.3
---
@@ -35,6 +35,31 @@
**Total Tests:** 108 passing
### Week 2: Inventory & Equipment - COMPLETE
| Task | Description | Status | Tests |
|------|-------------|--------|-------|
| 2.1 | Item Data Models (Affixes) | ✅ Complete | 24 tests |
| 2.2 | Item Data Files (YAML) | ✅ Complete | - |
| 2.2.1 | Item Generator Service | ✅ Complete | 35 tests |
| 2.3 | Inventory Service | ✅ Complete | 24 tests |
| 2.4 | Inventory API Endpoints | ✅ Complete | 25 tests |
| 2.5 | Character Stats Calculation | ✅ Complete | 17 tests |
| 2.6 | Equipment-Combat Integration | ✅ Complete | 140 tests |
**Files Created/Modified:**
- `/api/app/models/items.py` - Item with affix support, spell_power field
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
- `/api/app/models/stats.py` - spell_power_bonus, updated damage formula
- `/api/app/models/combat.py` - Combatant weapon properties
- `/api/app/services/item_generator.py` - Procedural item generation
- `/api/app/services/inventory_service.py` - Equipment management
- `/api/app/services/damage_calculator.py` - Refactored to use stats properties
- `/api/app/services/combat_service.py` - Equipment integration
- `/api/app/api/inventory.py` - REST API endpoints
**Total Tests (Week 2):** 265+ passing
---
## Overview
@@ -973,7 +998,7 @@ app.register_blueprint(combat_bp, url_prefix='/api/v1/combat')
---
### Week 2: Inventory & Equipment System ⏳ IN PROGRESS
### Week 2: Inventory & Equipment System ✅ COMPLETE
#### Task 2.1: Item Data Models ✅ COMPLETE
@@ -1563,81 +1588,172 @@ character.inventory.append(generated_item.to_dict()) # Store full item data
---
#### Task 2.5: Update Character Stats Calculation (4 hours)
#### Task 2.5: Update Character Stats Calculation (4 hours) ✅ COMPLETE
**Objective:** Ensure `get_effective_stats()` includes equipped items
**Objective:** Ensure `get_effective_stats()` includes equipped items' combat bonuses
**File:** `/api/app/models/character.py`
**Files Modified:**
- `/api/app/models/stats.py` - Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields
- `/api/app/models/character.py` - Updated `get_effective_stats()` to populate bonus fields
**Update Method:**
**Implementation Summary:**
The Stats model now has three equipment bonus fields that are populated by `get_effective_stats()`:
```python
def get_effective_stats(self) -> Stats:
"""
Calculate effective stats including base, equipment, skills, and effects.
# Stats model additions
damage_bonus: int = 0 # From weapons
defense_bonus: int = 0 # From armor
resistance_bonus: int = 0 # From armor
Returns:
Stats instance with all modifiers applied
"""
# Start with base stats
effective = Stats(
strength=self.stats.strength,
defense=self.stats.defense,
speed=self.stats.speed,
intelligence=self.stats.intelligence,
resistance=self.stats.resistance,
vitality=self.stats.vitality,
spirit=self.stats.spirit
)
# Updated computed properties
@property
def damage(self) -> int:
return (self.strength // 2) + self.damage_bonus
# Add bonuses from equipped items
from app.services.item_loader import ItemLoader
item_loader = ItemLoader()
@property
def defense(self) -> int:
return (self.constitution // 2) + self.defense_bonus
for slot, item_id in self.equipped.items():
item = item_loader.get_item(item_id)
if not item:
continue
# Add item stat bonuses
if hasattr(item, 'stat_bonuses'):
for stat_name, bonus in item.stat_bonuses.items():
current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus)
# Armor adds defense/resistance
if item.item_type == ItemType.ARMOR:
effective.defense += item.defense
effective.resistance += item.resistance
# Add bonuses from unlocked skills
for skill_id in self.unlocked_skills:
skill = self.skill_tree.get_skill_node(skill_id)
if skill and skill.stat_bonuses:
for stat_name, bonus in skill.stat_bonuses.items():
current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus)
# Add temporary effects (buffs/debuffs)
for effect in self.active_effects:
if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
modifier = effect.power * effect.stacks
if effect.effect_type == EffectType.DEBUFF:
modifier *= -1
current_value = getattr(effective, effect.stat_type)
new_value = max(1, current_value + modifier) # Min stat is 1
setattr(effective, effect.stat_type, new_value)
return effective
@property
def resistance(self) -> int:
return (self.wisdom // 2) + self.resistance_bonus
```
**Acceptance Criteria:**
- Equipped weapons add damage
- Equipped armor adds defense/resistance
- Stat bonuses from items apply correctly
- Skills still apply bonuses
- Effects still modify stats
The `get_effective_stats()` method now applies:
1. `stat_bonuses` dict from all equipped items (as before)
2. Weapon `damage` → `damage_bonus`
3. Armor `defense` → `defense_bonus`
4. Armor `resistance` → `resistance_bonus`
**Tests Added:**
- `/api/tests/test_stats.py` - 11 new tests for bonus fields
- `/api/tests/test_character.py` - 6 new tests for equipment combat bonuses
**Acceptance Criteria:** ✅ MET
- [x] Equipped weapons add damage (via `damage_bonus`)
- [x] Equipped armor adds defense/resistance (via `defense_bonus`/`resistance_bonus`)
- [x] Stat bonuses from items apply correctly
- [x] Skills still apply bonuses
- [x] Effects still modify stats
---
#### Task 2.6: Equipment-Combat Integration (4 hours) ✅ COMPLETE
**Objective:** Fully integrate equipment stats into combat damage calculations, replacing hardcoded weapon damage values with effective_stats properties.
**Files Modified:**
- `/api/app/models/stats.py` - Updated damage formula, added spell_power system
- `/api/app/models/items.py` - Added spell_power field for magical weapons
- `/api/app/models/character.py` - Populate spell_power_bonus in get_effective_stats()
- `/api/app/models/combat.py` - Added weapon property fields to Combatant
- `/api/app/services/combat_service.py` - Updated combatant creation and attack execution
- `/api/app/services/damage_calculator.py` - Use stats properties instead of weapon_damage param
**Implementation Summary:**
**1. Updated Damage Formula (Stats Model)**
Changed damage scaling from `STR // 2` to `int(STR * 0.75)` for better progression:
```python
# Old formula
@property
def damage(self) -> int:
return (self.strength // 2) + self.damage_bonus
# New formula (0.75 scaling factor)
@property
def damage(self) -> int:
return int(self.strength * 0.75) + self.damage_bonus
```
**2. Added Spell Power System**
Symmetric system for magical weapons (staves, wands):
```python
# Stats model additions
spell_power_bonus: int = 0 # From magical weapons
@property
def spell_power(self) -> int:
"""Magical damage: int(INT * 0.75) + spell_power_bonus."""
return int(self.intelligence * 0.75) + self.spell_power_bonus
# Item model additions
spell_power: int = 0 # Spell power bonus for magical weapons
def is_magical_weapon(self) -> bool:
"""Check if this is a magical weapon (uses spell_power)."""
return self.is_weapon() and self.spell_power > 0
```
**3. Combatant Weapon Properties**
Added weapon properties to Combatant model for combat-time access:
```python
# Weapon combat properties
weapon_crit_chance: float = 0.05
weapon_crit_multiplier: float = 2.0
weapon_damage_type: Optional[DamageType] = None
# Elemental weapon support
elemental_damage_type: Optional[DamageType] = None
physical_ratio: float = 1.0
elemental_ratio: float = 0.0
```
**4. DamageCalculator Refactored**
Removed `weapon_damage` parameter - now uses `attacker_stats.damage` directly:
```python
# Old signature
def calculate_physical_damage(
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int, # Separate parameter
...
)
# New signature
def calculate_physical_damage(
attacker_stats: Stats, # stats.damage includes weapon bonus
defender_stats: Stats,
...
)
# Formula now uses:
base_damage = attacker_stats.damage + ability_base_power # Physical
base_damage = attacker_stats.spell_power + ability_base_power # Magical
```
**5. Combat Service Updates**
- `_create_combatant_from_character()` extracts weapon properties from equipped weapon
- `_create_combatant_from_enemy()` uses `stats.damage_bonus = template.base_damage`
- Removed hardcoded `_get_weapon_damage()` method
- `_execute_attack()` handles elemental weapons with split damage
**Tests Updated:**
- `/api/tests/test_stats.py` - Updated damage formula tests (0.75 scaling)
- `/api/tests/test_character.py` - Updated equipment bonus tests
- `/api/tests/test_damage_calculator.py` - Removed weapon_damage parameter from calls
- `/api/tests/test_combat_service.py` - Added `equipped` attribute to mock fixture
**Test Results:** 140 tests passing for all modified components
**Acceptance Criteria:** ✅ MET
- [x] Damage uses `effective_stats.damage` (includes weapon bonus)
- [x] Spell power uses `effective_stats.spell_power` (includes staff/wand bonus)
- [x] 0.75 scaling factor for both physical and magical damage
- [x] Weapon crit chance/multiplier flows through to combat
- [x] Elemental weapons support split physical/elemental damage
- [x] Enemy combatants use template base_damage correctly
- [x] All existing tests pass with updated formulas
---