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
306 lines
10 KiB
Python
306 lines
10 KiB
Python
"""
|
|
Item affix system for procedural item generation.
|
|
|
|
This module defines affixes (prefixes and suffixes) that can be attached to items
|
|
to provide stat bonuses and generate Diablo-style names like "Flaming Dagger of Strength".
|
|
"""
|
|
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
from app.models.enums import AffixType, AffixTier, DamageType, ItemRarity
|
|
|
|
|
|
@dataclass
|
|
class Affix:
|
|
"""
|
|
Represents a single item affix (prefix or suffix).
|
|
|
|
Affixes provide stat bonuses and contribute to item naming.
|
|
Prefixes appear before the item name: "Flaming Dagger"
|
|
Suffixes appear after the item name: "Dagger of Strength"
|
|
|
|
Attributes:
|
|
affix_id: Unique identifier (e.g., "flaming", "of_strength")
|
|
name: Display name for the affix (e.g., "Flaming", "of Strength")
|
|
affix_type: PREFIX or SUFFIX
|
|
tier: MINOR, MAJOR, or LEGENDARY (determines bonus magnitude)
|
|
description: Human-readable description of the affix effect
|
|
|
|
Stat Bonuses:
|
|
stat_bonuses: Dict mapping stat name to bonus value
|
|
Example: {"strength": 2, "constitution": 1}
|
|
defense_bonus: Direct defense bonus
|
|
resistance_bonus: Direct resistance bonus
|
|
|
|
Weapon Properties (PREFIX only, elemental):
|
|
damage_bonus: Flat damage bonus added to weapon
|
|
damage_type: Elemental damage type (fire, ice, etc.)
|
|
elemental_ratio: Portion of damage converted to elemental (0.0-1.0)
|
|
crit_chance_bonus: Added to weapon crit chance
|
|
crit_multiplier_bonus: Added to crit damage multiplier
|
|
|
|
Restrictions:
|
|
allowed_item_types: Empty list = all types allowed
|
|
required_rarity: Minimum rarity to roll this affix (for legendary-only)
|
|
"""
|
|
|
|
affix_id: str
|
|
name: str
|
|
affix_type: AffixType
|
|
tier: AffixTier
|
|
description: str = ""
|
|
|
|
# Stat bonuses (applies to any item)
|
|
stat_bonuses: Dict[str, int] = field(default_factory=dict)
|
|
defense_bonus: int = 0
|
|
resistance_bonus: int = 0
|
|
|
|
# Weapon-specific bonuses
|
|
damage_bonus: int = 0
|
|
damage_type: Optional[DamageType] = None
|
|
elemental_ratio: float = 0.0
|
|
crit_chance_bonus: float = 0.0
|
|
crit_multiplier_bonus: float = 0.0
|
|
|
|
# Restrictions
|
|
allowed_item_types: List[str] = field(default_factory=list)
|
|
required_rarity: Optional[str] = None
|
|
|
|
def applies_elemental_damage(self) -> bool:
|
|
"""
|
|
Check if this affix converts damage to elemental.
|
|
|
|
Returns:
|
|
True if affix adds elemental damage component
|
|
"""
|
|
return self.damage_type is not None and self.elemental_ratio > 0.0
|
|
|
|
def is_legendary_only(self) -> bool:
|
|
"""
|
|
Check if this affix only rolls on legendary items.
|
|
|
|
Returns:
|
|
True if affix requires legendary rarity
|
|
"""
|
|
return self.required_rarity == "legendary"
|
|
|
|
def can_apply_to(self, item_type: str, rarity: str) -> bool:
|
|
"""
|
|
Check if this affix can be applied to an item.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor", etc.)
|
|
rarity: Item rarity ("common", "rare", "epic", "legendary")
|
|
|
|
Returns:
|
|
True if affix can be applied, False otherwise
|
|
"""
|
|
# Check rarity requirement
|
|
if self.required_rarity and rarity != self.required_rarity:
|
|
return False
|
|
|
|
# Check item type restriction
|
|
if self.allowed_item_types and item_type not in self.allowed_item_types:
|
|
return False
|
|
|
|
return True
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""
|
|
Serialize affix to dictionary.
|
|
|
|
Returns:
|
|
Dictionary containing all affix data
|
|
"""
|
|
data = asdict(self)
|
|
data["affix_type"] = self.affix_type.value
|
|
data["tier"] = self.tier.value
|
|
if self.damage_type:
|
|
data["damage_type"] = self.damage_type.value
|
|
return data
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> 'Affix':
|
|
"""
|
|
Deserialize affix from dictionary.
|
|
|
|
Args:
|
|
data: Dictionary containing affix data
|
|
|
|
Returns:
|
|
Affix instance
|
|
"""
|
|
affix_type = AffixType(data["affix_type"])
|
|
tier = AffixTier(data["tier"])
|
|
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
|
|
|
return cls(
|
|
affix_id=data["affix_id"],
|
|
name=data["name"],
|
|
affix_type=affix_type,
|
|
tier=tier,
|
|
description=data.get("description", ""),
|
|
stat_bonuses=data.get("stat_bonuses", {}),
|
|
defense_bonus=data.get("defense_bonus", 0),
|
|
resistance_bonus=data.get("resistance_bonus", 0),
|
|
damage_bonus=data.get("damage_bonus", 0),
|
|
damage_type=damage_type,
|
|
elemental_ratio=data.get("elemental_ratio", 0.0),
|
|
crit_chance_bonus=data.get("crit_chance_bonus", 0.0),
|
|
crit_multiplier_bonus=data.get("crit_multiplier_bonus", 0.0),
|
|
allowed_item_types=data.get("allowed_item_types", []),
|
|
required_rarity=data.get("required_rarity"),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation of the affix."""
|
|
bonuses = []
|
|
if self.stat_bonuses:
|
|
bonuses.append(f"stats={self.stat_bonuses}")
|
|
if self.damage_bonus:
|
|
bonuses.append(f"dmg+{self.damage_bonus}")
|
|
if self.defense_bonus:
|
|
bonuses.append(f"def+{self.defense_bonus}")
|
|
if self.applies_elemental_damage():
|
|
bonuses.append(f"{self.damage_type.value}={self.elemental_ratio:.0%}")
|
|
|
|
bonus_str = ", ".join(bonuses) if bonuses else "no bonuses"
|
|
return f"Affix({self.name}, {self.affix_type.value}, {self.tier.value}, {bonus_str})"
|
|
|
|
|
|
@dataclass
|
|
class BaseItemTemplate:
|
|
"""
|
|
Template for base items used in procedural generation.
|
|
|
|
Base items define the foundation (e.g., "Dagger", "Longsword", "Chainmail")
|
|
that affixes attach to during item generation.
|
|
|
|
Attributes:
|
|
template_id: Unique identifier (e.g., "dagger", "longsword")
|
|
name: Display name (e.g., "Dagger", "Longsword")
|
|
item_type: Category ("weapon", "armor")
|
|
description: Flavor text for the base item
|
|
|
|
Base Stats:
|
|
base_damage: Base weapon damage (weapons only)
|
|
base_defense: Base armor defense (armor only)
|
|
base_resistance: Base magic resistance (armor only)
|
|
base_value: Base gold value before rarity/affix modifiers
|
|
|
|
Weapon Properties:
|
|
damage_type: Primary damage type (usually "physical")
|
|
crit_chance: Base critical hit chance
|
|
crit_multiplier: Base critical damage multiplier
|
|
|
|
Generation:
|
|
required_level: Minimum character level for this template
|
|
drop_weight: Weighting for random selection (higher = more common)
|
|
min_rarity: Minimum rarity this template can generate at
|
|
"""
|
|
|
|
template_id: str
|
|
name: str
|
|
item_type: str # "weapon" or "armor"
|
|
description: str = ""
|
|
|
|
# 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
|
|
|
|
# Weapon properties
|
|
damage_type: str = "physical"
|
|
crit_chance: float = 0.05
|
|
crit_multiplier: float = 2.0
|
|
|
|
# Generation settings
|
|
required_level: int = 1
|
|
drop_weight: float = 1.0
|
|
min_rarity: str = "common"
|
|
|
|
def can_generate_at_rarity(self, rarity: str) -> bool:
|
|
"""
|
|
Check if this template can generate at a given rarity.
|
|
|
|
Some templates (like greatswords) may only drop at rare+.
|
|
|
|
Args:
|
|
rarity: Target rarity to check
|
|
|
|
Returns:
|
|
True if template can generate at this rarity
|
|
"""
|
|
rarity_order = ["common", "uncommon", "rare", "epic", "legendary"]
|
|
min_index = rarity_order.index(self.min_rarity)
|
|
target_index = rarity_order.index(rarity)
|
|
return target_index >= min_index
|
|
|
|
def can_drop_for_level(self, character_level: int) -> bool:
|
|
"""
|
|
Check if this template can drop for a character level.
|
|
|
|
Args:
|
|
character_level: Character's current level
|
|
|
|
Returns:
|
|
True if template can drop for this level
|
|
"""
|
|
return character_level >= self.required_level
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""
|
|
Serialize template to dictionary.
|
|
|
|
Returns:
|
|
Dictionary containing all template data
|
|
"""
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> 'BaseItemTemplate':
|
|
"""
|
|
Deserialize template from dictionary.
|
|
|
|
Args:
|
|
data: Dictionary containing template data
|
|
|
|
Returns:
|
|
BaseItemTemplate instance
|
|
"""
|
|
return cls(
|
|
template_id=data["template_id"],
|
|
name=data["name"],
|
|
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),
|
|
damage_type=data.get("damage_type", "physical"),
|
|
crit_chance=data.get("crit_chance", 0.05),
|
|
crit_multiplier=data.get("crit_multiplier", 2.0),
|
|
required_level=data.get("required_level", 1),
|
|
drop_weight=data.get("drop_weight", 1.0),
|
|
min_rarity=data.get("min_rarity", "common"),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation of the template."""
|
|
if self.item_type == "weapon":
|
|
return (
|
|
f"BaseItemTemplate({self.name}, weapon, dmg={self.base_damage}, "
|
|
f"crit={self.crit_chance*100:.0f}%, lvl={self.required_level})"
|
|
)
|
|
elif self.item_type == "armor":
|
|
return (
|
|
f"BaseItemTemplate({self.name}, armor, def={self.base_defense}, "
|
|
f"res={self.base_resistance}, lvl={self.required_level})"
|
|
)
|
|
else:
|
|
return f"BaseItemTemplate({self.name}, {self.item_type})"
|