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:
@@ -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),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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%})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user