Files
Code_of_Conquest/api/app/services/static_item_loader.py

302 lines
9.4 KiB
Python

"""
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, DamageType
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", {})
# Parse damage type if present (for weapons)
damage_type = None
damage_type_str = template.get("damage_type")
if damage_type_str:
try:
damage_type = DamageType(damage_type_str)
except ValueError:
logger.warning(
"Unknown damage type, defaulting to physical",
damage_type=damage_type_str,
item_id=item_id
)
damage_type = DamageType.PHYSICAL
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,
# Weapon-specific fields
damage=template.get("damage", 0),
spell_power=template.get("spell_power", 0),
damage_type=damage_type,
crit_chance=template.get("crit_chance", 0.05),
crit_multiplier=template.get("crit_multiplier", 2.0),
# Armor-specific fields
defense=template.get("defense", 0),
resistance=template.get("resistance", 0),
# Level requirements
required_level=template.get("required_level", 1),
)
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