Files
Code_of_Conquest/api/app/models/affixes.py
Phillip Tarrant 185be7fee0 feat(api): implement Diablo-style item affix system
Add procedural item generation with affix naming system:
- Items with RARE/EPIC/LEGENDARY rarity get dynamic names
- Prefixes (e.g., "Flaming") add elemental damage, material bonuses
- Suffixes (e.g., "of Strength") add stat bonuses
- Affix count scales with rarity: RARE=1, EPIC=2, LEGENDARY=3

New files:
- models/affixes.py: Affix and BaseItemTemplate dataclasses
- services/affix_loader.py: YAML-based affix pool loading
- services/base_item_loader.py: Base item template loading
- services/item_generator.py: Main procedural generation service
- data/affixes/prefixes.yaml: 14 prefix definitions
- data/affixes/suffixes.yaml: 15 suffix definitions
- data/base_items/weapons.yaml: 12 weapon templates
- data/base_items/armor.yaml: 12 armor templates
- tests/test_item_generator.py: 34 comprehensive tests

Modified:
- enums.py: Added AffixType and AffixTier enums
- items.py: Added affix tracking fields (applied_affixes, generated_name)

Example output: "Frozen Dagger of the Bear" (EPIC with ice damage + STR/CON)
2025-11-26 17:57:34 -06:00

304 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_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_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})"