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)
274 lines
8.2 KiB
Python
274 lines
8.2 KiB
Python
"""
|
|
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
|