Files
Code_of_Conquest/api/app/models/items.py
Phillip Tarrant a38906b445 feat(api): integrate equipment stats into combat damage system
Equipment-Combat Integration:
- Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling
- Add spell_power system for magical weapons (staves, wands)
- Add spell_power_bonus field to Stats model with spell_power property
- Add spell_power field to Item model with is_magical_weapon() method
- Update Character.get_effective_stats() to populate spell_power_bonus

Combatant Model Updates:
- Add weapon property fields (crit_chance, crit_multiplier, damage_type)
- Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio)
- Update serialization to handle new weapon properties

DamageCalculator Refactoring:
- Remove weapon_damage parameter from calculate_physical_damage()
- Use attacker_stats.damage directly (includes weapon bonus)
- Use attacker_stats.spell_power for magical damage calculations

Combat Service Updates:
- Extract weapon properties in _create_combatant_from_character()
- Use stats.damage_bonus for enemy combatants from templates
- Remove hardcoded _get_weapon_damage() method
- Handle elemental weapons with split damage in _execute_attack()

Item Generation Updates:
- Add base_spell_power to BaseItemTemplate dataclass
- Add ARCANE damage type to DamageType enum
- Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand)

Test Updates:
- Update test_stats.py for new damage formula (0.75 scaling)
- Update test_character.py for equipment bonus calculations
- Update test_damage_calculator.py for new API signatures
- Update test_combat_service.py mock fixture for equipped attribute

Tests: 174 passing
2025-11-26 19:54:58 -06:00

284 lines
10 KiB
Python

"""
Item system for equipment, consumables, and quest items.
This module defines the Item dataclass representing all types of items in the game,
including weapons, armor, consumables, and quest items.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from app.models.enums import ItemType, ItemRarity, DamageType
from app.models.effects import Effect
@dataclass
class Item:
"""
Represents an item in the game (weapon, armor, consumable, or quest item).
Items can provide passive stat bonuses when equipped, have weapon/armor stats,
or provide effects when consumed.
Attributes:
item_id: Unique identifier
name: Display name
item_type: Category (weapon, armor, consumable, quest_item)
rarity: Rarity tier (common, uncommon, rare, epic, legendary)
description: Item lore and information
value: Gold value for buying/selling
is_tradeable: Whether item can be sold on marketplace
stat_bonuses: Passive bonuses to stats when equipped
Example: {"strength": 5, "constitution": 3}
effects_on_use: Effects applied when consumed (consumables only)
Weapon-specific attributes:
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
Armor-specific attributes:
defense: Physical defense bonus
resistance: Magical resistance bonus
Requirements (future):
required_level: Minimum character level to use
required_class: Class restriction (if any)
"""
item_id: str
name: str
item_type: ItemType
rarity: ItemRarity = ItemRarity.COMMON
description: str = ""
value: int = 0
is_tradeable: bool = True
# Passive bonuses (equipment)
stat_bonuses: Dict[str, int] = field(default_factory=dict)
# Active effects (consumables)
effects_on_use: List[Effect] = field(default_factory=list)
# Weapon-specific
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
# Elemental weapon properties (for split damage like Fire Sword)
# These enable weapons to deal both physical AND elemental damage
elemental_damage_type: Optional[DamageType] = None # Secondary damage type (fire, ice, etc.)
physical_ratio: float = 1.0 # Portion of damage that is physical (0.0-1.0)
elemental_ratio: float = 0.0 # Portion of damage that is elemental (0.0-1.0)
# Armor-specific
defense: int = 0
resistance: int = 0
# Requirements (future expansion)
required_level: int = 1
required_class: Optional[str] = None
# Affix tracking (for procedurally generated items)
applied_affixes: List[str] = field(default_factory=list) # List of affix_ids
base_template_id: Optional[str] = None # ID of base item template used
generated_name: Optional[str] = None # Full generated name with affixes
is_generated: bool = False # True if created by item generator
def get_display_name(self) -> str:
"""
Get the item's display name.
For generated items, returns the affix-enhanced name.
For static items, returns the base name.
Returns:
Display name string
"""
return self.generated_name or self.name
def is_weapon(self) -> bool:
"""Check if this item is a weapon."""
return self.item_type == ItemType.WEAPON
def is_armor(self) -> bool:
"""Check if this item is armor."""
return self.item_type == ItemType.ARMOR
def is_consumable(self) -> bool:
"""Check if this item is a consumable."""
return self.item_type == ItemType.CONSUMABLE
def is_quest_item(self) -> bool:
"""Check if this item is a quest item."""
return self.item_type == ItemType.QUEST_ITEM
def is_elemental_weapon(self) -> bool:
"""
Check if this weapon deals elemental damage (split damage).
Elemental weapons deal both physical AND elemental damage,
calculated separately against DEF and RES.
Examples:
Fire Sword: 70% physical / 30% fire
Frost Blade: 60% physical / 40% ice
Lightning Spear: 50% physical / 50% lightning
Returns:
True if weapon has elemental damage component
"""
return (
self.is_weapon() and
self.elemental_ratio > 0.0 and
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.
Args:
character_level: Character's current level
character_class: Character's class (if class restrictions exist)
Returns:
True if item can be equipped, False otherwise
"""
# Check level requirement
if character_level < self.required_level:
return False
# Check class requirement
if self.required_class and character_class != self.required_class:
return False
return True
def get_total_stat_bonus(self, stat_name: str) -> int:
"""
Get the total bonus for a specific stat from this item.
Args:
stat_name: Name of the stat (e.g., "strength", "intelligence")
Returns:
Bonus value for that stat (0 if not present)
"""
return self.stat_bonuses.get(stat_name, 0)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize item to a dictionary.
Returns:
Dictionary containing all item data
"""
data = asdict(self)
data["item_type"] = self.item_type.value
data["rarity"] = self.rarity.value
if self.damage_type:
data["damage_type"] = self.damage_type.value
if self.elemental_damage_type:
data["elemental_damage_type"] = self.elemental_damage_type.value
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
# Include display_name for convenience
data["display_name"] = self.get_display_name()
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Item':
"""
Deserialize item from a dictionary.
Args:
data: Dictionary containing item data
Returns:
Item instance
"""
# Convert string values back to enums
item_type = ItemType(data["item_type"])
rarity = ItemRarity(data.get("rarity", "common"))
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
elemental_damage_type = (
DamageType(data["elemental_damage_type"])
if data.get("elemental_damage_type")
else None
)
# Deserialize effects
effects = []
if "effects_on_use" in data and data["effects_on_use"]:
effects = [Effect.from_dict(e) for e in data["effects_on_use"]]
return cls(
item_id=data["item_id"],
name=data["name"],
item_type=item_type,
rarity=rarity,
description=data.get("description", ""),
value=data.get("value", 0),
is_tradeable=data.get("is_tradeable", True),
stat_bonuses=data.get("stat_bonuses", {}),
effects_on_use=effects,
damage=data.get("damage", 0),
damage_type=damage_type,
crit_chance=data.get("crit_chance", 0.05),
crit_multiplier=data.get("crit_multiplier", 2.0),
elemental_damage_type=elemental_damage_type,
physical_ratio=data.get("physical_ratio", 1.0),
elemental_ratio=data.get("elemental_ratio", 0.0),
defense=data.get("defense", 0),
resistance=data.get("resistance", 0),
required_level=data.get("required_level", 1),
required_class=data.get("required_class"),
# Affix tracking fields
applied_affixes=data.get("applied_affixes", []),
base_template_id=data.get("base_template_id"),
generated_name=data.get("generated_name"),
is_generated=data.get("is_generated", False),
)
def __repr__(self) -> str:
"""String representation of the item."""
if self.is_weapon():
if self.is_elemental_weapon():
return (
f"Item({self.name}, elemental_weapon, dmg={self.damage}, "
f"phys={self.physical_ratio:.0%}/{self.elemental_damage_type.value}={self.elemental_ratio:.0%}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
)
return (
f"Item({self.name}, weapon, dmg={self.damage}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
)
elif self.is_armor():
return (
f"Item({self.name}, armor, def={self.defense}, "
f"res={self.resistance}, value={self.value}g)"
)
elif self.is_consumable():
return (
f"Item({self.name}, consumable, "
f"effects={len(self.effects_on_use)}, value={self.value}g)"
)
else:
return f"Item({self.name}, quest_item)"