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:
2025-11-27 00:01:17 -06:00
parent a38906b445
commit fdd48034e4
14 changed files with 2257 additions and 26 deletions

View File

@@ -0,0 +1,428 @@
"""
Tests for CombatLootService.
Tests the service that orchestrates loot generation from combat,
supporting both static and procedural loot drops.
"""
import pytest
from unittest.mock import Mock, patch
from app.services.combat_loot_service import (
CombatLootService,
LootContext,
get_combat_loot_service,
DIFFICULTY_RARITY_BONUS,
LUCK_CONVERSION_FACTOR
)
from app.models.enemy import (
EnemyTemplate,
EnemyDifficulty,
LootEntry,
LootType
)
from app.models.stats import Stats
from app.models.items import Item
from app.models.enums import ItemType, ItemRarity
class TestLootContext:
"""Test LootContext dataclass."""
def test_default_values(self):
"""Test default context values."""
context = LootContext()
assert context.party_average_level == 1
assert context.enemy_difficulty == EnemyDifficulty.EASY
assert context.luck_stat == 8
assert context.loot_bonus == 0.0
def test_custom_values(self):
"""Test creating context with custom values."""
context = LootContext(
party_average_level=10,
enemy_difficulty=EnemyDifficulty.HARD,
luck_stat=15,
loot_bonus=0.1
)
assert context.party_average_level == 10
assert context.enemy_difficulty == EnemyDifficulty.HARD
assert context.luck_stat == 15
assert context.loot_bonus == 0.1
class TestDifficultyBonuses:
"""Test difficulty rarity bonus constants."""
def test_easy_bonus(self):
"""Easy enemies have no bonus."""
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.EASY] == 0.0
def test_medium_bonus(self):
"""Medium enemies have small bonus."""
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.MEDIUM] == 0.05
def test_hard_bonus(self):
"""Hard enemies have moderate bonus."""
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.HARD] == 0.15
def test_boss_bonus(self):
"""Boss enemies have large bonus."""
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.BOSS] == 0.30
class TestCombatLootServiceInit:
"""Test service initialization."""
def test_init_uses_defaults(self):
"""Service should initialize with default dependencies."""
service = CombatLootService()
assert service.item_generator is not None
assert service.static_loader is not None
def test_singleton_returns_same_instance(self):
"""get_combat_loot_service should return singleton."""
service1 = get_combat_loot_service()
service2 = get_combat_loot_service()
assert service1 is service2
class TestCombatLootServiceEffectiveLuck:
"""Test effective luck calculation."""
def test_base_luck_no_bonus(self):
"""With no bonuses, effective luck equals base luck."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
rarity_bonus=0.0
)
context = LootContext(
luck_stat=8,
enemy_difficulty=EnemyDifficulty.EASY,
loot_bonus=0.0
)
effective = service._calculate_effective_luck(entry, context)
# No bonus, so effective should equal base
assert effective == 8
def test_difficulty_bonus_adds_luck(self):
"""Difficulty bonus should increase effective luck."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
rarity_bonus=0.0
)
context = LootContext(
luck_stat=8,
enemy_difficulty=EnemyDifficulty.BOSS, # 0.30 bonus
loot_bonus=0.0
)
effective = service._calculate_effective_luck(entry, context)
# Boss bonus = 0.30 * 20 = 6 extra luck
assert effective == 8 + 6
def test_entry_rarity_bonus_adds_luck(self):
"""Entry rarity bonus should increase effective luck."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
rarity_bonus=0.10 # Entry-specific bonus
)
context = LootContext(
luck_stat=8,
enemy_difficulty=EnemyDifficulty.EASY,
loot_bonus=0.0
)
effective = service._calculate_effective_luck(entry, context)
# 0.10 * 20 = 2 extra luck
assert effective == 8 + 2
def test_combined_bonuses(self):
"""All bonuses should stack."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
rarity_bonus=0.10
)
context = LootContext(
luck_stat=10,
enemy_difficulty=EnemyDifficulty.HARD, # 0.15
loot_bonus=0.05
)
effective = service._calculate_effective_luck(entry, context)
# Total bonus = 0.10 + 0.15 + 0.05 = 0.30
# Extra luck = 0.30 * 20 = 6
expected = 10 + 6
assert effective == expected
class TestCombatLootServiceStaticItems:
"""Test static item generation."""
def test_generate_static_items_returns_items(self):
"""Should return Item instances for static entries."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.STATIC,
item_id="health_potion_small",
drop_chance=1.0
)
items = service._generate_static_items(entry, quantity=1)
assert len(items) == 1
assert items[0].name == "Small Health Potion"
def test_generate_static_items_respects_quantity(self):
"""Should generate correct quantity of items."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.STATIC,
item_id="goblin_ear",
drop_chance=1.0
)
items = service._generate_static_items(entry, quantity=3)
assert len(items) == 3
# All should be goblin ears with unique IDs
for item in items:
assert "goblin_ear" in item.item_id
def test_generate_static_items_missing_id(self):
"""Should return empty list if item_id is missing."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.STATIC,
item_id=None,
drop_chance=1.0
)
items = service._generate_static_items(entry, quantity=1)
assert len(items) == 0
class TestCombatLootServiceProceduralItems:
"""Test procedural item generation."""
def test_generate_procedural_items_returns_items(self):
"""Should return generated Item instances."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
drop_chance=1.0,
rarity_bonus=0.0
)
context = LootContext(party_average_level=5)
items = service._generate_procedural_items(entry, quantity=1, context=context)
assert len(items) == 1
assert items[0].is_weapon()
def test_generate_procedural_armor(self):
"""Should generate armor when item_type is armor."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="armor",
drop_chance=1.0
)
context = LootContext(party_average_level=5)
items = service._generate_procedural_items(entry, quantity=1, context=context)
assert len(items) == 1
assert items[0].is_armor()
def test_generate_procedural_missing_type(self):
"""Should return empty list if item_type is missing."""
service = CombatLootService()
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type=None,
drop_chance=1.0
)
context = LootContext()
items = service._generate_procedural_items(entry, quantity=1, context=context)
assert len(items) == 0
class TestCombatLootServiceGenerateFromEnemy:
"""Test full loot generation from enemy templates."""
@pytest.fixture
def sample_enemy(self):
"""Create a sample enemy template for testing."""
return EnemyTemplate(
enemy_id="test_goblin",
name="Test Goblin",
description="A test goblin",
base_stats=Stats(),
abilities=["basic_attack"],
loot_table=[
LootEntry(
loot_type=LootType.STATIC,
item_id="goblin_ear",
drop_chance=1.0, # Guaranteed drop for testing
quantity_min=1,
quantity_max=1
)
],
experience_reward=10,
difficulty=EnemyDifficulty.EASY
)
def test_generate_loot_from_enemy_basic(self, sample_enemy):
"""Should generate loot from enemy loot table."""
service = CombatLootService()
context = LootContext()
items = service.generate_loot_from_enemy(sample_enemy, context)
assert len(items) == 1
assert "goblin_ear" in items[0].item_id
def test_generate_loot_respects_drop_chance(self):
"""Items with 0 drop chance should never drop."""
enemy = EnemyTemplate(
enemy_id="test_enemy",
name="Test Enemy",
description="Test",
base_stats=Stats(),
abilities=[],
loot_table=[
LootEntry(
loot_type=LootType.STATIC,
item_id="rare_item",
drop_chance=0.0, # Never drops
)
],
difficulty=EnemyDifficulty.EASY
)
service = CombatLootService()
context = LootContext()
# Run multiple times to ensure it never drops
for _ in range(10):
items = service.generate_loot_from_enemy(enemy, context)
assert len(items) == 0
def test_generate_loot_multiple_entries(self):
"""Should process all loot table entries."""
enemy = EnemyTemplate(
enemy_id="test_enemy",
name="Test Enemy",
description="Test",
base_stats=Stats(),
abilities=[],
loot_table=[
LootEntry(
loot_type=LootType.STATIC,
item_id="goblin_ear",
drop_chance=1.0,
),
LootEntry(
loot_type=LootType.STATIC,
item_id="health_potion_small",
drop_chance=1.0,
)
],
difficulty=EnemyDifficulty.EASY
)
service = CombatLootService()
context = LootContext()
items = service.generate_loot_from_enemy(enemy, context)
assert len(items) == 2
class TestCombatLootServiceBossLoot:
"""Test boss loot generation."""
@pytest.fixture
def boss_enemy(self):
"""Create a boss enemy template for testing."""
return EnemyTemplate(
enemy_id="test_boss",
name="Test Boss",
description="A test boss",
base_stats=Stats(strength=20, constitution=20),
abilities=["basic_attack"],
loot_table=[
LootEntry(
loot_type=LootType.STATIC,
item_id="goblin_chieftain_token",
drop_chance=1.0,
)
],
experience_reward=100,
difficulty=EnemyDifficulty.BOSS
)
def test_generate_boss_loot_includes_guaranteed_drops(self, boss_enemy):
"""Boss loot should include guaranteed equipment drops."""
service = CombatLootService()
context = LootContext(party_average_level=10)
items = service.generate_boss_loot(boss_enemy, context, guaranteed_drops=1)
# Should have at least the loot table drop + guaranteed drop
assert len(items) >= 2
def test_generate_boss_loot_non_boss_skips_guaranteed(self):
"""Non-boss enemies shouldn't get guaranteed drops."""
enemy = EnemyTemplate(
enemy_id="test_enemy",
name="Test Enemy",
description="Test",
base_stats=Stats(),
abilities=[],
loot_table=[
LootEntry(
loot_type=LootType.STATIC,
item_id="goblin_ear",
drop_chance=1.0,
)
],
difficulty=EnemyDifficulty.EASY # Not a boss
)
service = CombatLootService()
context = LootContext()
items = service.generate_boss_loot(enemy, context, guaranteed_drops=2)
# Should only have the one loot table drop
assert len(items) == 1

View File

@@ -623,14 +623,22 @@ class TestRewardsCalculation:
service = CombatService.__new__(CombatService)
service.enemy_loader = Mock()
service.character_service = Mock()
service.loot_service = Mock()
# Mock enemy template for rewards
mock_template = Mock()
mock_template.experience_reward = 50
mock_template.get_gold_reward.return_value = 25
mock_template.roll_loot.return_value = [{"item_id": "sword", "quantity": 1}]
mock_template.difficulty = Mock()
mock_template.difficulty.value = "easy"
mock_template.is_boss.return_value = False
service.enemy_loader.load_enemy.return_value = mock_template
# Mock loot service to return mock items
mock_item = Mock()
mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1}
service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
mock_session = Mock()
mock_session.is_solo.return_value = True
mock_session.solo_character_id = "test_char"

View File

@@ -0,0 +1,224 @@
"""
Tests for LootEntry model with hybrid loot support.
Tests the extended LootEntry dataclass that supports both static
and procedural loot types with backward compatibility.
"""
import pytest
from app.models.enemy import LootEntry, LootType
class TestLootEntryBackwardCompatibility:
"""Test that existing YAML format still works."""
def test_from_dict_defaults_to_static(self):
"""Old-style entries without loot_type should default to STATIC."""
entry_data = {
"item_id": "rusty_dagger",
"drop_chance": 0.15,
}
entry = LootEntry.from_dict(entry_data)
assert entry.loot_type == LootType.STATIC
assert entry.item_id == "rusty_dagger"
assert entry.drop_chance == 0.15
assert entry.quantity_min == 1
assert entry.quantity_max == 1
def test_from_dict_with_all_old_fields(self):
"""Test entry with all old-style fields."""
entry_data = {
"item_id": "gold_coin",
"drop_chance": 0.50,
"quantity_min": 1,
"quantity_max": 3,
}
entry = LootEntry.from_dict(entry_data)
assert entry.loot_type == LootType.STATIC
assert entry.item_id == "gold_coin"
assert entry.drop_chance == 0.50
assert entry.quantity_min == 1
assert entry.quantity_max == 3
def test_to_dict_includes_loot_type(self):
"""Serialization should include loot_type."""
entry = LootEntry(
loot_type=LootType.STATIC,
item_id="health_potion",
drop_chance=0.2
)
data = entry.to_dict()
assert data["loot_type"] == "static"
assert data["item_id"] == "health_potion"
assert data["drop_chance"] == 0.2
class TestLootEntryStaticType:
"""Test static loot entries."""
def test_static_entry_creation(self):
"""Test creating a static loot entry."""
entry = LootEntry(
loot_type=LootType.STATIC,
item_id="goblin_ear",
drop_chance=0.60,
quantity_min=1,
quantity_max=2
)
assert entry.loot_type == LootType.STATIC
assert entry.item_id == "goblin_ear"
assert entry.item_type is None
assert entry.rarity_bonus == 0.0
def test_static_from_dict_explicit(self):
"""Test parsing explicit static entry."""
entry_data = {
"loot_type": "static",
"item_id": "health_potion_small",
"drop_chance": 0.10,
}
entry = LootEntry.from_dict(entry_data)
assert entry.loot_type == LootType.STATIC
assert entry.item_id == "health_potion_small"
def test_static_to_dict_omits_procedural_fields(self):
"""Static entries should omit procedural-only fields."""
entry = LootEntry(
loot_type=LootType.STATIC,
item_id="gold_coin",
drop_chance=0.5
)
data = entry.to_dict()
assert "item_id" in data
assert "item_type" not in data
assert "rarity_bonus" not in data
class TestLootEntryProceduralType:
"""Test procedural loot entries."""
def test_procedural_entry_creation(self):
"""Test creating a procedural loot entry."""
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
drop_chance=0.10,
rarity_bonus=0.15
)
assert entry.loot_type == LootType.PROCEDURAL
assert entry.item_type == "weapon"
assert entry.rarity_bonus == 0.15
assert entry.item_id is None
def test_procedural_from_dict(self):
"""Test parsing procedural entry from dict."""
entry_data = {
"loot_type": "procedural",
"item_type": "armor",
"drop_chance": 0.08,
"rarity_bonus": 0.05,
}
entry = LootEntry.from_dict(entry_data)
assert entry.loot_type == LootType.PROCEDURAL
assert entry.item_type == "armor"
assert entry.drop_chance == 0.08
assert entry.rarity_bonus == 0.05
def test_procedural_to_dict_includes_item_type(self):
"""Procedural entries should include item_type and rarity_bonus."""
entry = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
drop_chance=0.15,
rarity_bonus=0.10
)
data = entry.to_dict()
assert data["loot_type"] == "procedural"
assert data["item_type"] == "weapon"
assert data["rarity_bonus"] == 0.10
assert "item_id" not in data
def test_procedural_default_rarity_bonus(self):
"""Procedural entries default to 0.0 rarity bonus."""
entry_data = {
"loot_type": "procedural",
"item_type": "weapon",
"drop_chance": 0.10,
}
entry = LootEntry.from_dict(entry_data)
assert entry.rarity_bonus == 0.0
class TestLootTypeEnum:
"""Test LootType enum values."""
def test_static_value(self):
"""Test STATIC enum value."""
assert LootType.STATIC.value == "static"
def test_procedural_value(self):
"""Test PROCEDURAL enum value."""
assert LootType.PROCEDURAL.value == "procedural"
def test_from_string(self):
"""Test creating enum from string."""
assert LootType("static") == LootType.STATIC
assert LootType("procedural") == LootType.PROCEDURAL
def test_invalid_string_raises(self):
"""Test that invalid string raises ValueError."""
with pytest.raises(ValueError):
LootType("invalid")
class TestLootEntryRoundTrip:
"""Test serialization/deserialization round trips."""
def test_static_round_trip(self):
"""Static entry should survive round trip."""
original = LootEntry(
loot_type=LootType.STATIC,
item_id="health_potion_small",
drop_chance=0.15,
quantity_min=1,
quantity_max=2
)
data = original.to_dict()
restored = LootEntry.from_dict(data)
assert restored.loot_type == original.loot_type
assert restored.item_id == original.item_id
assert restored.drop_chance == original.drop_chance
assert restored.quantity_min == original.quantity_min
assert restored.quantity_max == original.quantity_max
def test_procedural_round_trip(self):
"""Procedural entry should survive round trip."""
original = LootEntry(
loot_type=LootType.PROCEDURAL,
item_type="weapon",
drop_chance=0.25,
rarity_bonus=0.15,
quantity_min=1,
quantity_max=1
)
data = original.to_dict()
restored = LootEntry.from_dict(data)
assert restored.loot_type == original.loot_type
assert restored.item_type == original.item_type
assert restored.drop_chance == original.drop_chance
assert restored.rarity_bonus == original.rarity_bonus

View 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