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,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