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" name: "Wizard Staff"
item_type: "weapon" item_type: "weapon"
description: "A staff attuned to magical energy" description: "A staff attuned to magical energy"
base_damage: 8 base_damage: 4
base_spell_power: 12
base_value: 45 base_value: 45
damage_type: "physical" damage_type: "arcane"
crit_chance: 0.05 crit_chance: 0.05
crit_multiplier: 2.0 crit_multiplier: 2.0
required_level: 3 required_level: 3
drop_weight: 0.8 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 ==================== # ==================== RANGED ====================
shortbow: shortbow:
template_id: "shortbow" template_id: "shortbow"

View File

@@ -207,6 +207,7 @@ class BaseItemTemplate:
# Base stats # Base stats
base_damage: int = 0 base_damage: int = 0
base_spell_power: int = 0 # For magical weapons (staves, wands)
base_defense: int = 0 base_defense: int = 0
base_resistance: int = 0 base_resistance: int = 0
base_value: int = 10 base_value: int = 10
@@ -276,6 +277,7 @@ class BaseItemTemplate:
item_type=data["item_type"], item_type=data["item_type"],
description=data.get("description", ""), description=data.get("description", ""),
base_damage=data.get("base_damage", 0), base_damage=data.get("base_damage", 0),
base_spell_power=data.get("base_spell_power", 0),
base_defense=data.get("base_defense", 0), base_defense=data.get("base_defense", 0),
base_resistance=data.get("base_resistance", 0), base_resistance=data.get("base_resistance", 0),
base_value=data.get("base_value", 10), 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.items import Item
from app.models.skills import PlayerClass, SkillNode from app.models.skills import PlayerClass, SkillNode
from app.models.effects import Effect 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 from app.models.origins import Origin
@@ -92,7 +92,11 @@ class Character:
This is the CRITICAL METHOD that combines: This is the CRITICAL METHOD that combines:
1. Base stats (from character) 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) 3. Skill tree bonuses (from unlocked skills)
4. Active effect modifiers (buffs/debuffs) 4. Active effect modifiers (buffs/debuffs)
@@ -100,18 +104,30 @@ class Character:
active_effects: Currently active effects on this character (from combat) active_effects: Currently active effects on this character (from combat)
Returns: 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 # Start with a copy of base stats
effective = self.base_stats.copy() effective = self.base_stats.copy()
# Apply equipment bonuses # Apply equipment bonuses
for item in self.equipped.values(): for item in self.equipped.values():
# Apply stat bonuses from item (e.g., +3 strength)
for stat_name, bonus in item.stat_bonuses.items(): for stat_name, bonus in item.stat_bonuses.items():
if hasattr(effective, stat_name): if hasattr(effective, stat_name):
current_value = getattr(effective, stat_name) current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus) 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 # Apply skill tree bonuses
skill_bonuses = self._get_skill_bonuses() skill_bonuses = self._get_skill_bonuses()
for stat_name, bonus in skill_bonuses.items(): 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.stats import Stats
from app.models.effects import Effect from app.models.effects import Effect
from app.models.abilities import Ability from app.models.abilities import Ability
from app.models.enums import CombatStatus, EffectType from app.models.enums import CombatStatus, EffectType, DamageType
@dataclass @dataclass
@@ -36,6 +36,12 @@ class Combatant:
abilities: Available abilities for this combatant abilities: Available abilities for this combatant
cooldowns: Map of ability_id to turns remaining cooldowns: Map of ability_id to turns remaining
initiative: Turn order value (rolled at combat start) 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 combatant_id: str
@@ -51,6 +57,16 @@ class Combatant:
cooldowns: Dict[str, int] = field(default_factory=dict) cooldowns: Dict[str, int] = field(default_factory=dict)
initiative: int = 0 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: def is_alive(self) -> bool:
"""Check if combatant is still alive.""" """Check if combatant is still alive."""
return self.current_hp > 0 return self.current_hp > 0
@@ -228,6 +244,12 @@ class Combatant:
"abilities": self.abilities, "abilities": self.abilities,
"cooldowns": self.cooldowns, "cooldowns": self.cooldowns,
"initiative": self.initiative, "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 @classmethod
@@ -236,6 +258,15 @@ class Combatant:
stats = Stats.from_dict(data["stats"]) stats = Stats.from_dict(data["stats"])
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])] 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( return cls(
combatant_id=data["combatant_id"], combatant_id=data["combatant_id"],
name=data["name"], name=data["name"],
@@ -249,6 +280,12 @@ class Combatant:
abilities=data.get("abilities", []), abilities=data.get("abilities", []),
cooldowns=data.get("cooldowns", {}), cooldowns=data.get("cooldowns", {}),
initiative=data.get("initiative", 0), 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 HOLY = "holy" # Holy/divine damage
SHADOW = "shadow" # Dark/shadow magic damage SHADOW = "shadow" # Dark/shadow magic damage
POISON = "poison" # Poison damage (usually DoT) POISON = "poison" # Poison damage (usually DoT)
ARCANE = "arcane" # Pure magical damage (staves, wands)
class ItemType(Enum): class ItemType(Enum):

View File

@@ -33,7 +33,8 @@ class Item:
effects_on_use: Effects applied when consumed (consumables only) effects_on_use: Effects applied when consumed (consumables only)
Weapon-specific attributes: 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.) damage_type: Type of damage (physical, fire, etc.)
crit_chance: Probability of critical hit (0.0 to 1.0) crit_chance: Probability of critical hit (0.0 to 1.0)
crit_multiplier: Damage multiplier on critical hit crit_multiplier: Damage multiplier on critical hit
@@ -62,7 +63,8 @@ class Item:
effects_on_use: List[Effect] = field(default_factory=list) effects_on_use: List[Effect] = field(default_factory=list)
# Weapon-specific # 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 damage_type: Optional[DamageType] = None
crit_chance: float = 0.05 # 5% default critical hit chance crit_chance: float = 0.05 # 5% default critical hit chance
crit_multiplier: float = 2.0 # 2x damage on critical hit crit_multiplier: float = 2.0 # 2x damage on critical hit
@@ -136,6 +138,18 @@ class Item:
self.elemental_damage_type is not None 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: def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
""" """
Check if a character can equip this item. Check if a character can equip this item.

View File

@@ -22,12 +22,18 @@ class Stats:
wisdom: Perception and insight, affects magical resistance wisdom: Perception and insight, affects magical resistance
charisma: Social influence, affects NPC interactions charisma: Social influence, affects NPC interactions
luck: Fortune and fate, affects critical hits, loot, and random outcomes 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: Computed Properties:
hit_points: Maximum HP = 10 + (constitution × 2) hit_points: Maximum HP = 10 + (constitution × 2)
mana_points: Maximum MP = 10 + (intelligence × 2) mana_points: Maximum MP = 10 + (intelligence × 2)
defense: Physical defense = constitution // 2 damage: Physical damage = int(strength × 0.75) + damage_bonus
resistance: Magical resistance = wisdom // 2 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 strength: int = 10
@@ -38,6 +44,12 @@ class Stats:
charisma: int = 10 charisma: int = 10
luck: int = 8 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 @property
def hit_points(self) -> int: def hit_points(self) -> int:
""" """
@@ -62,29 +74,65 @@ class Stats:
""" """
return 10 + (self.intelligence * 2) 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 @property
def defense(self) -> int: 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: Returns:
Physical defense value (damage reduction) Physical defense value (damage reduction)
""" """
return self.constitution // 2 return (self.constitution // 2) + self.defense_bonus
@property @property
def resistance(self) -> int: 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: Returns:
Magical resistance value (spell damage reduction) Magical resistance value (spell damage reduction)
""" """
return self.wisdom // 2 return (self.wisdom // 2) + self.resistance_bonus
@property @property
def crit_bonus(self) -> float: def crit_bonus(self) -> float:
@@ -171,6 +219,10 @@ class Stats:
wisdom=data.get("wisdom", 10), wisdom=data.get("wisdom", 10),
charisma=data.get("charisma", 10), charisma=data.get("charisma", 10),
luck=data.get("luck", 8), 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': def copy(self) -> 'Stats':
@@ -188,6 +240,10 @@ class Stats:
wisdom=self.wisdom, wisdom=self.wisdom,
charisma=self.charisma, charisma=self.charisma,
luck=self.luck, 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: def __repr__(self) -> str:
@@ -197,6 +253,7 @@ class Stats:
f"CON={self.constitution}, INT={self.intelligence}, " f"CON={self.constitution}, INT={self.intelligence}, "
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, " f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
f"HP={self.hit_points}, MP={self.mana_points}, " 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"DEF={self.defense}, RES={self.resistance}, "
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})" 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 # Set defaults for missing optional fields
template_data.setdefault("description", "") template_data.setdefault("description", "")
template_data.setdefault("base_damage", 0) template_data.setdefault("base_damage", 0)
template_data.setdefault("base_spell_power", 0)
template_data.setdefault("base_defense", 0) template_data.setdefault("base_defense", 0)
template_data.setdefault("base_resistance", 0) template_data.setdefault("base_resistance", 0)
template_data.setdefault("base_value", 10) template_data.setdefault("base_value", 10)

View File

@@ -571,17 +571,26 @@ class CombatService:
message="Invalid or dead target" message="Invalid or dead target"
) )
# Get attacker's weapon damage (or base damage for enemies) # Check if this is an elemental weapon attack
weapon_damage = self._get_weapon_damage(attacker) if attacker.elemental_ratio > 0.0 and attacker.elemental_damage_type:
crit_chance = self._get_crit_chance(attacker) # Elemental weapon: split damage between physical and elemental
damage_result = DamageCalculator.calculate_elemental_weapon_damage(
# Calculate damage using DamageCalculator attacker_stats=attacker.stats,
damage_result = DamageCalculator.calculate_physical_damage( defender_stats=target.stats,
attacker_stats=attacker.stats, weapon_crit_chance=attacker.weapon_crit_chance,
defender_stats=target.stats, weapon_crit_multiplier=attacker.weapon_crit_multiplier,
weapon_damage=weapon_damage, physical_ratio=attacker.physical_ratio,
weapon_crit_chance=crit_chance, 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 # Add target_id to result for tracking
damage_result.target_id = target.combatant_id damage_result.target_id = target.combatant_id
@@ -970,6 +979,25 @@ class CombatService:
abilities = ["basic_attack"] # All characters have basic attack abilities = ["basic_attack"] # All characters have basic attack
abilities.extend(character.unlocked_skills) 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( return Combatant(
combatant_id=character.character_id, combatant_id=character.character_id,
name=character.name, name=character.name,
@@ -980,6 +1008,12 @@ class CombatService:
max_mp=effective_stats.mana_points, max_mp=effective_stats.mana_points,
stats=effective_stats, stats=effective_stats,
abilities=abilities, 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( def _create_combatant_from_enemy(
@@ -996,7 +1030,9 @@ class CombatService:
if instance_index > 0: if instance_index > 0:
name = f"{template.name} #{instance_index + 1}" 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( return Combatant(
combatant_id=combatant_id, combatant_id=combatant_id,
@@ -1008,23 +1044,15 @@ class CombatService:
max_mp=stats.mana_points, max_mp=stats.mana_points,
stats=stats, stats=stats,
abilities=template.abilities.copy(), 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: def _get_crit_chance(self, combatant: Combatant) -> float:
"""Get critical hit chance for a combatant.""" """Get critical hit chance for a combatant."""
# Base 5% + LUK bonus # Weapon crit chance + LUK bonus
return 0.05 + combatant.stats.crit_bonus return combatant.weapon_crit_chance + combatant.stats.crit_bonus
def _get_default_target( def _get_default_target(
self, self,

View File

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

View File

@@ -317,6 +317,7 @@ class ItemGenerator:
# Base values from template # Base values from template
damage = base_template.base_damage + combined_stats["damage_bonus"] 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"] defense = base_template.base_defense + combined_stats["defense_bonus"]
resistance = base_template.base_resistance + combined_stats["resistance_bonus"] resistance = base_template.base_resistance + combined_stats["resistance_bonus"]
crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"] crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"]
@@ -353,6 +354,7 @@ class ItemGenerator:
stat_bonuses=combined_stats["stat_bonuses"], stat_bonuses=combined_stats["stat_bonuses"],
effects_on_use=[], # Not a consumable effects_on_use=[], # Not a consumable
damage=damage, damage=damage,
spell_power=spell_power, # Magical weapon damage bonus
damage_type=DamageType(base_template.damage_type) if damage > 0 else None, damage_type=DamageType(base_template.damage_type) if damage > 0 else None,
crit_chance=crit_chance, crit_chance=crit_chance,
crit_multiplier=crit_multiplier, 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 restored.unlocked_skills == basic_character.unlocked_skills
assert "weapon" in restored.equipped assert "weapon" in restored.equipped
assert restored.equipped["weapon"].item_id == "sword" 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.experience = 1000
char.gold = 100 char.gold = 100
char.unlocked_skills = ["power_strike"] char.unlocked_skills = ["power_strike"]
char.equipped = {} # No equipment by default
char.get_effective_stats = Mock(return_value=mock_stats) char.get_effective_stats = Mock(return_value=mock_stats)
return char return char

View File

@@ -267,8 +267,9 @@ class TestPhysicalDamage:
def test_basic_physical_damage_formula(self): def test_basic_physical_damage_formula(self):
"""Test the basic physical damage formula.""" """Test the basic physical damage formula."""
# Formula: (Weapon + STR * 0.75) * Variance - DEF # Formula: (stats.damage + ability_power) * Variance - DEF
attacker = Stats(strength=14, luck=0) # LUK 0 to ensure no miss # 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 defender = Stats(constitution=10, dexterity=10) # DEF = 5
# Mock to ensure no miss and no crit, variance = 1.0 # Mock to ensure no miss and no crit, variance = 1.0
@@ -278,10 +279,9 @@ class TestPhysicalDamage:
result = DamageCalculator.calculate_physical_damage( result = DamageCalculator.calculate_physical_damage(
attacker_stats=attacker, attacker_stats=attacker,
defender_stats=defender, 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.total_damage == 13
assert result.is_miss is False assert result.is_miss is False
assert result.is_critical is False assert result.is_critical is False
@@ -289,7 +289,7 @@ class TestPhysicalDamage:
def test_physical_damage_miss(self): def test_physical_damage_miss(self):
"""Test that misses deal zero damage.""" """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 defender = Stats(dexterity=30) # Very high DEX
# Force a miss # Force a miss
@@ -297,7 +297,6 @@ class TestPhysicalDamage:
result = DamageCalculator.calculate_physical_damage( result = DamageCalculator.calculate_physical_damage(
attacker_stats=attacker, attacker_stats=attacker,
defender_stats=defender, defender_stats=defender,
weapon_damage=8,
) )
assert result.is_miss is True assert result.is_miss is True
@@ -306,7 +305,7 @@ class TestPhysicalDamage:
def test_physical_damage_critical_hit(self): def test_physical_damage_critical_hit(self):
"""Test critical hit doubles damage.""" """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) defender = Stats(constitution=10, dexterity=10)
# Force hit and crit # Force hit and crit
@@ -315,15 +314,14 @@ class TestPhysicalDamage:
result = DamageCalculator.calculate_physical_damage( result = DamageCalculator.calculate_physical_damage(
attacker_stats=attacker, attacker_stats=attacker,
defender_stats=defender, defender_stats=defender,
weapon_damage=8,
weapon_crit_multiplier=2.0, weapon_crit_multiplier=2.0,
) )
assert result.is_critical is True assert result.is_critical is True
# Base: 8 + 14*0.75 = 18.5 # Base: int(14 * 0.75) + 8 = 10 + 8 = 18
# Crit applied BEFORE int conversion: 18.5 * 2 = 37 # Crit: 18 * 2 = 36
# After DEF 5: 37 - 5 = 32 # After DEF 5: 36 - 5 = 31
assert result.total_damage == 32 assert result.total_damage == 31
assert "critical" in result.message.lower() assert "critical" in result.message.lower()
@@ -405,7 +403,8 @@ class TestElementalWeaponDamage:
def test_split_damage_calculation(self): def test_split_damage_calculation(self):
"""Test 70/30 physical/fire split damage.""" """Test 70/30 physical/fire split damage."""
# Fire Sword: 70% physical, 30% fire # 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 defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
@@ -414,7 +413,6 @@ class TestElementalWeaponDamage:
result = DamageCalculator.calculate_elemental_weapon_damage( result = DamageCalculator.calculate_elemental_weapon_damage(
attacker_stats=attacker, attacker_stats=attacker,
defender_stats=defender, defender_stats=defender,
weapon_damage=15,
weapon_crit_chance=0.05, weapon_crit_chance=0.05,
weapon_crit_multiplier=2.0, weapon_crit_multiplier=2.0,
physical_ratio=0.7, physical_ratio=0.7,
@@ -422,9 +420,10 @@ class TestElementalWeaponDamage:
elemental_type=DamageType.FIRE, elemental_type=DamageType.FIRE,
) )
# Physical: (15 * 0.7) + (14 * 0.75 * 0.7) - 5 = 10.5 + 7.35 - 5 = 12.85 -> 12 # stats.damage = int(14 * 0.75) + 15 = 10 + 15 = 25
# Elemental: (15 * 0.3) + (8 * 0.75 * 0.3) - 5 = 4.5 + 1.8 - 5 = 1.3 -> 1 # stats.spell_power = int(8 * 0.75) + 15 = 6 + 15 = 21
# Total: 12 + 1 = 13 (approximately, depends on min damage) # 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.physical_damage > 0
assert result.elemental_damage >= 1 # At least minimum damage assert result.elemental_damage >= 1 # At least minimum damage
@@ -433,7 +432,8 @@ class TestElementalWeaponDamage:
def test_50_50_split_damage(self): def test_50_50_split_damage(self):
"""Test 50/50 physical/elemental split (Lightning Spear).""" """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) defender = Stats(constitution=10, wisdom=10, dexterity=10)
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
@@ -442,7 +442,6 @@ class TestElementalWeaponDamage:
result = DamageCalculator.calculate_elemental_weapon_damage( result = DamageCalculator.calculate_elemental_weapon_damage(
attacker_stats=attacker, attacker_stats=attacker,
defender_stats=defender, defender_stats=defender,
weapon_damage=20,
weapon_crit_chance=0.05, weapon_crit_chance=0.05,
weapon_crit_multiplier=2.0, weapon_crit_multiplier=2.0,
physical_ratio=0.5, physical_ratio=0.5,
@@ -450,12 +449,12 @@ class TestElementalWeaponDamage:
elemental_type=DamageType.LIGHTNING, 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 assert abs(result.physical_damage - result.elemental_damage) <= 2
def test_elemental_crit_applies_to_both_components(self): def test_elemental_crit_applies_to_both_components(self):
"""Test that crit multiplier applies to both damage types.""" """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) defender = Stats(constitution=10, wisdom=10, dexterity=10)
# Force hit and crit # Force hit and crit
@@ -464,7 +463,6 @@ class TestElementalWeaponDamage:
result = DamageCalculator.calculate_elemental_weapon_damage( result = DamageCalculator.calculate_elemental_weapon_damage(
attacker_stats=attacker, attacker_stats=attacker,
defender_stats=defender, defender_stats=defender,
weapon_damage=15,
weapon_crit_chance=0.05, weapon_crit_chance=0.05,
weapon_crit_multiplier=2.0, weapon_crit_multiplier=2.0,
physical_ratio=0.7, physical_ratio=0.7,
@@ -614,8 +612,8 @@ class TestCombatIntegration:
def test_vanguard_attack_scenario(self): def test_vanguard_attack_scenario(self):
"""Test Vanguard (STR 14) basic attack.""" """Test Vanguard (STR 14) basic attack."""
# Vanguard: STR 14, LUK 8 # Vanguard: STR 14, LUK 8, equipped with Rusty Sword (8 damage)
vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8) vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8, damage_bonus=8)
goblin = Stats(constitution=10, dexterity=10) # DEF = 5 goblin = Stats(constitution=10, dexterity=10) # DEF = 5
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
@@ -624,15 +622,14 @@ class TestCombatIntegration:
result = DamageCalculator.calculate_physical_damage( result = DamageCalculator.calculate_physical_damage(
attacker_stats=vanguard, attacker_stats=vanguard,
defender_stats=goblin, 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 assert result.total_damage == 13
def test_arcanist_fireball_scenario(self): def test_arcanist_fireball_scenario(self):
"""Test Arcanist (INT 15) Fireball.""" """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) arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9)
goblin = Stats(wisdom=10, dexterity=10) # RES = 5 goblin = Stats(wisdom=10, dexterity=10) # RES = 5
@@ -646,14 +643,15 @@ class TestCombatIntegration:
damage_type=DamageType.FIRE, 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 assert result.total_damage == 18
def test_physical_vs_magical_balance(self): def test_physical_vs_magical_balance(self):
"""Test that physical and magical damage are comparable.""" """Test that physical and magical damage are comparable."""
# Same-tier characters should deal similar damage # Same-tier characters should deal similar damage
vanguard = Stats(strength=14, luck=8) # Melee vanguard = Stats(strength=14, luck=8, damage_bonus=8) # Melee with weapon
arcanist = Stats(intelligence=15, luck=9) # Caster arcanist = Stats(intelligence=15, luck=9) # Caster (no staff)
target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5 target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
@@ -662,7 +660,6 @@ class TestCombatIntegration:
phys_result = DamageCalculator.calculate_physical_damage( phys_result = DamageCalculator.calculate_physical_damage(
attacker_stats=vanguard, attacker_stats=vanguard,
defender_stats=target, defender_stats=target,
weapon_damage=8,
) )
magic_result = DamageCalculator.calculate_magical_damage( magic_result = DamageCalculator.calculate_magical_damage(
attacker_stats=arcanist, attacker_stats=arcanist,

View File

@@ -248,3 +248,134 @@ def test_repr_includes_combat_bonuses():
assert "CRIT_BONUS=" in repr_str assert "CRIT_BONUS=" in repr_str
assert "HIT_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 # 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 **Timeline:** 4-5 weeks
**Last Updated:** November 26, 2025 **Last Updated:** November 26, 2025
**Document Version:** 1.1 **Document Version:** 1.3
--- ---
@@ -35,6 +35,31 @@
**Total Tests:** 108 passing **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 ## 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 #### 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 ```python
def get_effective_stats(self) -> Stats: # Stats model additions
""" damage_bonus: int = 0 # From weapons
Calculate effective stats including base, equipment, skills, and effects. defense_bonus: int = 0 # From armor
resistance_bonus: int = 0 # From armor
Returns: # Updated computed properties
Stats instance with all modifiers applied @property
""" def damage(self) -> int:
# Start with base stats return (self.strength // 2) + self.damage_bonus
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
)
# Add bonuses from equipped items @property
from app.services.item_loader import ItemLoader def defense(self) -> int:
item_loader = ItemLoader() return (self.constitution // 2) + self.defense_bonus
for slot, item_id in self.equipped.items(): @property
item = item_loader.get_item(item_id) def resistance(self) -> int:
if not item: return (self.wisdom // 2) + self.resistance_bonus
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
``` ```
**Acceptance Criteria:** The `get_effective_stats()` method now applies:
- Equipped weapons add damage 1. `stat_bonuses` dict from all equipped items (as before)
- Equipped armor adds defense/resistance 2. Weapon `damage` → `damage_bonus`
- Stat bonuses from items apply correctly 3. Armor `defense` → `defense_bonus`
- Skills still apply bonuses 4. Armor `resistance` → `resistance_bonus`
- Effects still modify stats
**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
--- ---