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
284 lines
10 KiB
Python
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)"
|