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)
This commit is contained in:
303
api/app/models/affixes.py
Normal file
303
api/app/models/affixes.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
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})"
|
||||
Reference in New Issue
Block a user