""" Static Item Loader Service - YAML-based static item loading. This service loads predefined item definitions (consumables, materials, quest items) from YAML files, providing a way to reference specific items by ID in loot tables. Static items differ from procedurally generated items in that they have fixed properties defined in YAML rather than randomly generated affixes. """ from pathlib import Path from typing import Dict, List, Optional import uuid import yaml from app.models.items import Item from app.models.effects import Effect from app.models.enums import ItemType, ItemRarity, EffectType from app.utils.logging import get_logger logger = get_logger(__file__) class StaticItemLoader: """ Loads and manages static item definitions from YAML configuration files. Static items are predefined items (consumables, materials, quest items) that can be referenced by item_id in enemy loot tables. Items are loaded from: - api/app/data/static_items/consumables.yaml - api/app/data/static_items/materials.yaml Each call to get_item() creates a new Item instance with a unique ID, so multiple drops of the same item_id become distinct inventory items. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the static item loader. Args: data_dir: Path to directory containing static item YAML files. Defaults to /app/data/static_items/ """ if data_dir is None: # Default to app/data/static_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" / "static_items") self.data_dir = Path(data_dir) self._cache: Dict[str, dict] = {} self._loaded = False logger.info("StaticItemLoader initialized", data_dir=str(self.data_dir)) def _ensure_loaded(self) -> None: """Ensure items are loaded before any operation.""" if not self._loaded: self._load_all() def _load_all(self) -> None: """Load all static item YAML files.""" if not self.data_dir.exists(): logger.warning( "Static items directory not found", path=str(self.data_dir) ) self._loaded = True return # Load all YAML files in the directory for yaml_file in self.data_dir.glob("*.yaml"): self._load_file(yaml_file) self._loaded = True logger.info("Static items loaded", count=len(self._cache)) def _load_file(self, yaml_file: Path) -> None: """ Load items from a single YAML file. Args: yaml_file: Path to the YAML file """ try: with open(yaml_file, 'r') as f: data = yaml.safe_load(f) if data is None: logger.warning("Empty YAML file", file=str(yaml_file)) return items = data.get("items", {}) for item_id, item_data in items.items(): # Store the template data with its ID item_data["_item_id"] = item_id self._cache[item_id] = item_data logger.debug( "Static items loaded from file", file=str(yaml_file), count=len(items) ) except Exception as e: logger.error( "Failed to load static items file", file=str(yaml_file), error=str(e) ) def get_item(self, item_id: str, quantity: int = 1) -> Optional[Item]: """ Get an item instance by ID. Creates a new Item instance with a unique ID for each call, so multiple drops become distinct inventory items. Args: item_id: The static item ID (e.g., "health_potion_small") quantity: Requested quantity (not used for individual item, but available for future stackable item support) Returns: Item instance or None if item_id not found """ self._ensure_loaded() template = self._cache.get(item_id) if template is None: logger.warning("Static item not found", item_id=item_id) return None # Create new instance with unique ID instance_id = f"{item_id}_{uuid.uuid4().hex[:8]}" # Parse item type item_type_str = template.get("item_type", "quest_item") try: item_type = ItemType(item_type_str) except ValueError: logger.warning( "Unknown item type, defaulting to quest_item", item_type=item_type_str, item_id=item_id ) item_type = ItemType.QUEST_ITEM # Parse rarity rarity_str = template.get("rarity", "common") try: rarity = ItemRarity(rarity_str) except ValueError: logger.warning( "Unknown rarity, defaulting to common", rarity=rarity_str, item_id=item_id ) rarity = ItemRarity.COMMON # Parse effects if present effects = [] for effect_data in template.get("effects_on_use", []): try: effect = self._parse_effect(effect_data) if effect: effects.append(effect) except Exception as e: logger.warning( "Failed to parse effect", item_id=item_id, error=str(e) ) # Parse stat bonuses if present stat_bonuses = template.get("stat_bonuses", {}) return Item( item_id=instance_id, name=template.get("name", item_id), item_type=item_type, rarity=rarity, description=template.get("description", ""), value=template.get("value", 1), is_tradeable=template.get("is_tradeable", True), stat_bonuses=stat_bonuses, effects_on_use=effects, ) def _parse_effect(self, effect_data: Dict) -> Optional[Effect]: """ Parse an effect from YAML data. Supports simplified YAML format where effect_type is a string. Args: effect_data: Effect definition from YAML Returns: Effect instance or None if parsing fails """ # Parse effect type effect_type_str = effect_data.get("effect_type", "buff") try: effect_type = EffectType(effect_type_str) except ValueError: logger.warning( "Unknown effect type", effect_type=effect_type_str ) return None # Generate effect ID if not provided effect_id = effect_data.get( "effect_id", f"effect_{uuid.uuid4().hex[:8]}" ) return Effect( effect_id=effect_id, name=effect_data.get("name", "Unknown Effect"), effect_type=effect_type, duration=effect_data.get("duration", 1), power=effect_data.get("power", 0), stacks=effect_data.get("stacks", 1), max_stacks=effect_data.get("max_stacks", 5), ) def get_all_item_ids(self) -> List[str]: """ Get list of all available static item IDs. Returns: List of item_id strings """ self._ensure_loaded() return list(self._cache.keys()) def has_item(self, item_id: str) -> bool: """ Check if an item ID exists. Args: item_id: The item ID to check Returns: True if item exists in cache """ self._ensure_loaded() return item_id in self._cache def clear_cache(self) -> None: """Clear the item cache, forcing reload on next access.""" self._cache.clear() self._loaded = False logger.debug("Static item cache cleared") # Global instance for convenience _loader_instance: Optional[StaticItemLoader] = None def get_static_item_loader() -> StaticItemLoader: """ Get the global StaticItemLoader instance. Returns: Singleton StaticItemLoader instance """ global _loader_instance if _loader_instance is None: _loader_instance = StaticItemLoader() return _loader_instance