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

View File

@@ -0,0 +1,273 @@
"""
Base Item Loader Service - YAML-based base item template loading.
This service loads base item templates (weapons, armor) from YAML files,
providing the foundation for procedural item generation.
"""
from pathlib import Path
from typing import Dict, List, Optional
import random
import yaml
from app.models.affixes import BaseItemTemplate
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Rarity order for comparison
RARITY_ORDER = {
"common": 0,
"uncommon": 1,
"rare": 2,
"epic": 3,
"legendary": 4
}
class BaseItemLoader:
"""
Loads and manages base item templates from YAML configuration files.
This allows game designers to define base items without touching code.
Templates are organized into weapons.yaml and armor.yaml files.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the base item loader.
Args:
data_dir: Path to directory containing base item YAML files
Defaults to /app/data/base_items/
"""
if data_dir is None:
# Default to app/data/base_items relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "base_items")
self.data_dir = Path(data_dir)
self._weapon_cache: Dict[str, BaseItemTemplate] = {}
self._armor_cache: Dict[str, BaseItemTemplate] = {}
self._loaded = False
logger.info("BaseItemLoader initialized", data_dir=str(self.data_dir))
def _ensure_loaded(self) -> None:
"""Ensure templates are loaded before any operation."""
if not self._loaded:
self.load_all()
def load_all(self) -> None:
"""Load all base item templates from YAML files."""
if not self.data_dir.exists():
logger.warning("Base item data directory not found", path=str(self.data_dir))
return
# Load weapons
weapons_file = self.data_dir / "weapons.yaml"
if weapons_file.exists():
self._load_templates_from_file(weapons_file, "weapons", self._weapon_cache)
# Load armor
armor_file = self.data_dir / "armor.yaml"
if armor_file.exists():
self._load_templates_from_file(armor_file, "armor", self._armor_cache)
self._loaded = True
logger.info(
"Base item templates loaded",
weapon_count=len(self._weapon_cache),
armor_count=len(self._armor_cache)
)
def _load_templates_from_file(
self,
yaml_file: Path,
key: str,
cache: Dict[str, BaseItemTemplate]
) -> None:
"""
Load templates from a YAML file into the cache.
Args:
yaml_file: Path to the YAML file
key: Top-level key in YAML (e.g., "weapons", "armor")
cache: Cache dictionary to populate
"""
try:
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
templates_data = data.get(key, {})
for template_id, template_data in templates_data.items():
# Ensure template_id is set
template_data["template_id"] = template_id
# Set defaults for missing optional fields
template_data.setdefault("description", "")
template_data.setdefault("base_damage", 0)
template_data.setdefault("base_defense", 0)
template_data.setdefault("base_resistance", 0)
template_data.setdefault("base_value", 10)
template_data.setdefault("damage_type", "physical")
template_data.setdefault("crit_chance", 0.05)
template_data.setdefault("crit_multiplier", 2.0)
template_data.setdefault("required_level", 1)
template_data.setdefault("drop_weight", 1.0)
template_data.setdefault("min_rarity", "common")
template = BaseItemTemplate.from_dict(template_data)
cache[template.template_id] = template
logger.debug(
"Templates loaded from file",
file=str(yaml_file),
count=len(templates_data)
)
except Exception as e:
logger.error(
"Failed to load base item file",
file=str(yaml_file),
error=str(e)
)
def get_template(self, template_id: str) -> Optional[BaseItemTemplate]:
"""
Get a specific template by ID.
Args:
template_id: Unique template identifier
Returns:
BaseItemTemplate instance or None if not found
"""
self._ensure_loaded()
if template_id in self._weapon_cache:
return self._weapon_cache[template_id]
if template_id in self._armor_cache:
return self._armor_cache[template_id]
return None
def get_eligible_templates(
self,
item_type: str,
rarity: str,
character_level: int = 1
) -> List[BaseItemTemplate]:
"""
Get all templates eligible for generation.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Target rarity
character_level: Player level for eligibility
Returns:
List of eligible BaseItemTemplate instances
"""
self._ensure_loaded()
# Select the appropriate cache
if item_type == "weapon":
cache = self._weapon_cache
elif item_type == "armor":
cache = self._armor_cache
else:
logger.warning("Unknown item type", item_type=item_type)
return []
eligible = []
for template in cache.values():
# Check level requirement
if not template.can_drop_for_level(character_level):
continue
# Check rarity requirement
if not template.can_generate_at_rarity(rarity):
continue
eligible.append(template)
return eligible
def get_random_template(
self,
item_type: str,
rarity: str,
character_level: int = 1
) -> Optional[BaseItemTemplate]:
"""
Get a random eligible template, weighted by drop_weight.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Target rarity
character_level: Player level for eligibility
Returns:
Random eligible BaseItemTemplate or None if none available
"""
eligible = self.get_eligible_templates(item_type, rarity, character_level)
if not eligible:
logger.warning(
"No templates match criteria",
item_type=item_type,
rarity=rarity,
level=character_level
)
return None
# Weighted random selection based on drop_weight
weights = [t.drop_weight for t in eligible]
return random.choices(eligible, weights=weights, k=1)[0]
def get_all_weapons(self) -> Dict[str, BaseItemTemplate]:
"""
Get all cached weapon templates.
Returns:
Dictionary of weapon templates
"""
self._ensure_loaded()
return self._weapon_cache.copy()
def get_all_armor(self) -> Dict[str, BaseItemTemplate]:
"""
Get all cached armor templates.
Returns:
Dictionary of armor templates
"""
self._ensure_loaded()
return self._armor_cache.copy()
def clear_cache(self) -> None:
"""Clear the template cache, forcing reload on next access."""
self._weapon_cache.clear()
self._armor_cache.clear()
self._loaded = False
logger.debug("Base item template cache cleared")
# Global instance for convenience
_loader_instance: Optional[BaseItemLoader] = None
def get_base_item_loader() -> BaseItemLoader:
"""
Get the global BaseItemLoader instance.
Returns:
Singleton BaseItemLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = BaseItemLoader()
return _loader_instance