Add CombatLootService that orchestrates loot generation from combat, supporting both static item drops (consumables, materials) and procedural equipment generation (weapons, armor with affixes). Key changes: - Extend LootEntry model with LootType enum (STATIC/PROCEDURAL) - Create StaticItemLoader service for consumables/materials from YAML - Create CombatLootService with full rarity formula incorporating: - Party average level - Enemy difficulty tier (EASY: +0%, MEDIUM: +5%, HARD: +15%, BOSS: +30%) - Character luck stat - Per-entry rarity bonus - Integrate with CombatService._calculate_rewards() for automatic loot gen - Add boss guaranteed drops via generate_boss_loot() New enemy variants (goblin family proof-of-concept): - goblin_scout (Easy) - static drops only - goblin_warrior (Medium) - static + procedural weapon drops - goblin_chieftain (Hard) - static + procedural weapon/armor drops Static items added: - consumables.yaml: health/mana potions, elixirs, food - materials.yaml: trophy items, crafting materials Tests: 59 new tests across 3 test files (all passing)
277 lines
8.4 KiB
Python
277 lines
8.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
|
|
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
|