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:
2025-11-26 17:57:34 -06:00
parent f3ac0c8647
commit 185be7fee0
11 changed files with 2658 additions and 0 deletions

303
api/app/models/affixes.py Normal file
View 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})"

View File

@@ -50,6 +50,21 @@ class ItemRarity(Enum):
LEGENDARY = "legendary" # Orange/gold - best items
class AffixType(Enum):
"""Types of item affixes for procedural item generation."""
PREFIX = "prefix" # Appears before item name: "Flaming Dagger"
SUFFIX = "suffix" # Appears after item name: "Dagger of Strength"
class AffixTier(Enum):
"""Affix power tiers determining bonus magnitudes."""
MINOR = "minor" # Weaker bonuses, rolls on RARE items
MAJOR = "major" # Medium bonuses, rolls on EPIC items
LEGENDARY = "legendary" # Strongest bonuses, LEGENDARY only
class StatType(Enum):
"""Character attribute types."""

View File

@@ -81,6 +81,24 @@ class Item:
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
@@ -166,6 +184,8 @@ class Item:
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
@@ -215,6 +235,11 @@ class Item:
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: