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:
273
api/app/services/base_item_loader.py
Normal file
273
api/app/services/base_item_loader.py
Normal 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
|
||||
Reference in New Issue
Block a user