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)
225 lines
6.9 KiB
Python
225 lines
6.9 KiB
Python
"""
|
|
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
|