feat(api): implement combat loot integration with hybrid static/procedural system
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)
This commit is contained in:
194
api/tests/test_static_item_loader.py
Normal file
194
api/tests/test_static_item_loader.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Tests for StaticItemLoader service.
|
||||
|
||||
Tests the service that loads predefined items (consumables, materials)
|
||||
from YAML files for use in loot tables.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.static_item_loader import StaticItemLoader, get_static_item_loader
|
||||
from app.models.enums import ItemType, ItemRarity
|
||||
|
||||
|
||||
class TestStaticItemLoaderInitialization:
|
||||
"""Test service initialization."""
|
||||
|
||||
def test_init_with_default_path(self):
|
||||
"""Service should initialize with default data path."""
|
||||
loader = StaticItemLoader()
|
||||
assert loader.data_dir.exists() or not loader._loaded
|
||||
|
||||
def test_init_with_custom_path(self, tmp_path):
|
||||
"""Service should accept custom data path."""
|
||||
loader = StaticItemLoader(data_dir=str(tmp_path))
|
||||
assert loader.data_dir == tmp_path
|
||||
|
||||
def test_singleton_returns_same_instance(self):
|
||||
"""get_static_item_loader should return singleton."""
|
||||
loader1 = get_static_item_loader()
|
||||
loader2 = get_static_item_loader()
|
||||
assert loader1 is loader2
|
||||
|
||||
|
||||
class TestStaticItemLoaderLoading:
|
||||
"""Test YAML loading functionality."""
|
||||
|
||||
def test_loads_consumables(self):
|
||||
"""Should load consumable items from YAML."""
|
||||
loader = get_static_item_loader()
|
||||
|
||||
# Check that health potion exists
|
||||
assert loader.has_item("health_potion_small")
|
||||
assert loader.has_item("health_potion_medium")
|
||||
|
||||
def test_loads_materials(self):
|
||||
"""Should load material items from YAML."""
|
||||
loader = get_static_item_loader()
|
||||
|
||||
# Check that materials exist
|
||||
assert loader.has_item("goblin_ear")
|
||||
assert loader.has_item("wolf_pelt")
|
||||
|
||||
def test_get_all_item_ids_returns_list(self):
|
||||
"""get_all_item_ids should return list of item IDs."""
|
||||
loader = get_static_item_loader()
|
||||
item_ids = loader.get_all_item_ids()
|
||||
|
||||
assert isinstance(item_ids, list)
|
||||
assert len(item_ids) > 0
|
||||
assert "health_potion_small" in item_ids
|
||||
|
||||
def test_has_item_returns_false_for_missing(self):
|
||||
"""has_item should return False for non-existent items."""
|
||||
loader = get_static_item_loader()
|
||||
assert not loader.has_item("nonexistent_item_xyz")
|
||||
|
||||
|
||||
class TestStaticItemLoaderGetItem:
|
||||
"""Test item retrieval."""
|
||||
|
||||
def test_get_item_returns_item_object(self):
|
||||
"""get_item should return an Item instance."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_small")
|
||||
|
||||
assert item is not None
|
||||
assert item.name == "Small Health Potion"
|
||||
assert item.item_type == ItemType.CONSUMABLE
|
||||
assert item.rarity == ItemRarity.COMMON
|
||||
|
||||
def test_get_item_has_unique_id(self):
|
||||
"""Each call should create item with unique ID."""
|
||||
loader = get_static_item_loader()
|
||||
|
||||
item1 = loader.get_item("health_potion_small")
|
||||
item2 = loader.get_item("health_potion_small")
|
||||
|
||||
assert item1.item_id != item2.item_id
|
||||
assert "health_potion_small" in item1.item_id
|
||||
assert "health_potion_small" in item2.item_id
|
||||
|
||||
def test_get_item_returns_none_for_missing(self):
|
||||
"""get_item should return None for non-existent items."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("nonexistent_item_xyz")
|
||||
|
||||
assert item is None
|
||||
|
||||
def test_get_item_consumable_has_effects(self):
|
||||
"""Consumable items should have effects_on_use."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_small")
|
||||
|
||||
assert len(item.effects_on_use) > 0
|
||||
effect = item.effects_on_use[0]
|
||||
assert effect.name == "Minor Healing"
|
||||
assert effect.power > 0
|
||||
|
||||
def test_get_item_quest_item_type(self):
|
||||
"""Quest items should have correct type."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("goblin_ear")
|
||||
|
||||
assert item is not None
|
||||
assert item.item_type == ItemType.QUEST_ITEM
|
||||
assert item.rarity == ItemRarity.COMMON
|
||||
|
||||
def test_get_item_has_value(self):
|
||||
"""Items should have value set."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_small")
|
||||
|
||||
assert item.value > 0
|
||||
|
||||
def test_get_item_is_tradeable(self):
|
||||
"""Items should default to tradeable."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("goblin_ear")
|
||||
|
||||
assert item.is_tradeable is True
|
||||
|
||||
|
||||
class TestStaticItemLoaderVariousItems:
|
||||
"""Test loading various item types."""
|
||||
|
||||
def test_medium_health_potion(self):
|
||||
"""Test medium health potion properties."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_medium")
|
||||
|
||||
assert item is not None
|
||||
assert item.rarity == ItemRarity.UNCOMMON
|
||||
assert item.value > 25 # More expensive than small
|
||||
|
||||
def test_large_health_potion(self):
|
||||
"""Test large health potion properties."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_large")
|
||||
|
||||
assert item is not None
|
||||
assert item.rarity == ItemRarity.RARE
|
||||
|
||||
def test_chieftain_token_rarity(self):
|
||||
"""Test that chieftain token is rare."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("goblin_chieftain_token")
|
||||
|
||||
assert item is not None
|
||||
assert item.rarity == ItemRarity.RARE
|
||||
|
||||
def test_elixir_has_buff_effect(self):
|
||||
"""Test that elixirs have buff effects."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("elixir_of_strength")
|
||||
|
||||
if item: # Only test if item exists
|
||||
assert len(item.effects_on_use) > 0
|
||||
|
||||
|
||||
class TestStaticItemLoaderCache:
|
||||
"""Test caching behavior."""
|
||||
|
||||
def test_clear_cache(self):
|
||||
"""clear_cache should reset loaded state."""
|
||||
loader = StaticItemLoader()
|
||||
|
||||
# Trigger loading
|
||||
loader._ensure_loaded()
|
||||
assert loader._loaded is True
|
||||
|
||||
# Clear cache
|
||||
loader.clear_cache()
|
||||
assert loader._loaded is False
|
||||
assert len(loader._cache) == 0
|
||||
|
||||
def test_lazy_loading(self):
|
||||
"""Items should be loaded lazily on first access."""
|
||||
loader = StaticItemLoader()
|
||||
assert loader._loaded is False
|
||||
|
||||
# Access triggers loading
|
||||
_ = loader.has_item("health_potion_small")
|
||||
assert loader._loaded is True
|
||||
Reference in New Issue
Block a user