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:
85
api/app/data/enemies/goblin_chieftain.yaml
Normal file
85
api/app/data/enemies/goblin_chieftain.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
# Goblin Chieftain - Hard variant, elite tribe leader
|
||||
# A cunning and powerful goblin leader, adorned with trophies.
|
||||
# Commands respect through fear and violence, drops quality loot.
|
||||
|
||||
enemy_id: goblin_chieftain
|
||||
name: Goblin Chieftain
|
||||
description: >
|
||||
A large, scarred goblin wearing a crown of teeth and bones.
|
||||
The chieftain has clawed its way to leadership through countless
|
||||
battles and betrayals. It wields a well-maintained weapon stolen
|
||||
from a fallen adventurer and commands its tribe with an iron fist.
|
||||
|
||||
base_stats:
|
||||
strength: 16
|
||||
dexterity: 12
|
||||
constitution: 14
|
||||
intelligence: 10
|
||||
wisdom: 10
|
||||
charisma: 12
|
||||
luck: 12
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- shield_bash
|
||||
- intimidating_shout
|
||||
|
||||
loot_table:
|
||||
# Static drops - guaranteed materials
|
||||
- loot_type: static
|
||||
item_id: goblin_ear
|
||||
drop_chance: 1.0
|
||||
quantity_min: 2
|
||||
quantity_max: 3
|
||||
- loot_type: static
|
||||
item_id: goblin_chieftain_token
|
||||
drop_chance: 0.80
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: goblin_war_paint
|
||||
drop_chance: 0.50
|
||||
quantity_min: 1
|
||||
quantity_max: 2
|
||||
|
||||
# Consumable drops
|
||||
- loot_type: static
|
||||
item_id: health_potion_medium
|
||||
drop_chance: 0.40
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: elixir_of_strength
|
||||
drop_chance: 0.10
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
|
||||
# Procedural equipment drops - higher chance and rarity bonus
|
||||
- loot_type: procedural
|
||||
item_type: weapon
|
||||
drop_chance: 0.25
|
||||
rarity_bonus: 0.10
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: procedural
|
||||
item_type: armor
|
||||
drop_chance: 0.15
|
||||
rarity_bonus: 0.05
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
|
||||
experience_reward: 75
|
||||
gold_reward_min: 20
|
||||
gold_reward_max: 50
|
||||
difficulty: hard
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- goblinoid
|
||||
- leader
|
||||
- elite
|
||||
- armed
|
||||
|
||||
base_damage: 14
|
||||
crit_chance: 0.15
|
||||
flee_chance: 0.25
|
||||
56
api/app/data/enemies/goblin_scout.yaml
Normal file
56
api/app/data/enemies/goblin_scout.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
# Goblin Scout - Easy variant, agile but fragile
|
||||
# A fast, cowardly goblin that serves as a lookout for its tribe.
|
||||
# Quick to flee, drops minor loot and the occasional small potion.
|
||||
|
||||
enemy_id: goblin_scout
|
||||
name: Goblin Scout
|
||||
description: >
|
||||
A small, wiry goblin with oversized ears and beady yellow eyes.
|
||||
Goblin scouts are the first line of awareness for their tribes,
|
||||
often found lurking in shadows or perched in trees. They prefer
|
||||
to run rather than fight, but will attack if cornered.
|
||||
|
||||
base_stats:
|
||||
strength: 6
|
||||
dexterity: 14
|
||||
constitution: 5
|
||||
intelligence: 6
|
||||
wisdom: 10
|
||||
charisma: 4
|
||||
luck: 10
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
|
||||
loot_table:
|
||||
# Static drops - materials and consumables
|
||||
- loot_type: static
|
||||
item_id: goblin_ear
|
||||
drop_chance: 0.60
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: goblin_trinket
|
||||
drop_chance: 0.20
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: health_potion_small
|
||||
drop_chance: 0.08
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
|
||||
experience_reward: 10
|
||||
gold_reward_min: 1
|
||||
gold_reward_max: 4
|
||||
difficulty: easy
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- goblinoid
|
||||
- small
|
||||
- scout
|
||||
|
||||
base_damage: 3
|
||||
crit_chance: 0.08
|
||||
flee_chance: 0.70
|
||||
70
api/app/data/enemies/goblin_warrior.yaml
Normal file
70
api/app/data/enemies/goblin_warrior.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
# Goblin Warrior - Medium variant, trained fighter
|
||||
# A battle-hardened goblin wielding crude but effective weapons.
|
||||
# More dangerous than scouts, fights in organized groups.
|
||||
|
||||
enemy_id: goblin_warrior
|
||||
name: Goblin Warrior
|
||||
description: >
|
||||
A muscular goblin clad in scavenged armor and wielding a crude
|
||||
but deadly weapon. Goblin warriors are the backbone of any goblin
|
||||
warband, trained to fight rather than flee. They attack with
|
||||
surprising ferocity and coordination.
|
||||
|
||||
base_stats:
|
||||
strength: 12
|
||||
dexterity: 10
|
||||
constitution: 10
|
||||
intelligence: 6
|
||||
wisdom: 6
|
||||
charisma: 4
|
||||
luck: 8
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- shield_bash
|
||||
|
||||
loot_table:
|
||||
# Static drops - materials and consumables
|
||||
- loot_type: static
|
||||
item_id: goblin_ear
|
||||
drop_chance: 0.80
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: goblin_war_paint
|
||||
drop_chance: 0.15
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: health_potion_small
|
||||
drop_chance: 0.15
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- loot_type: static
|
||||
item_id: iron_ore
|
||||
drop_chance: 0.10
|
||||
quantity_min: 1
|
||||
quantity_max: 2
|
||||
|
||||
# Procedural equipment drops
|
||||
- loot_type: procedural
|
||||
item_type: weapon
|
||||
drop_chance: 0.08
|
||||
rarity_bonus: 0.0
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
|
||||
experience_reward: 25
|
||||
gold_reward_min: 5
|
||||
gold_reward_max: 15
|
||||
difficulty: medium
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- goblinoid
|
||||
- warrior
|
||||
- armed
|
||||
|
||||
base_damage: 8
|
||||
crit_chance: 0.10
|
||||
flee_chance: 0.45
|
||||
161
api/app/data/static_items/consumables.yaml
Normal file
161
api/app/data/static_items/consumables.yaml
Normal file
@@ -0,0 +1,161 @@
|
||||
# Consumable items that drop from enemies or are purchased from vendors
|
||||
# These items have effects_on_use that trigger when consumed
|
||||
|
||||
items:
|
||||
# ==========================================================================
|
||||
# Health Potions
|
||||
# ==========================================================================
|
||||
|
||||
health_potion_small:
|
||||
name: "Small Health Potion"
|
||||
item_type: consumable
|
||||
rarity: common
|
||||
description: "A small vial of red liquid that restores a modest amount of health."
|
||||
value: 25
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: heal_small
|
||||
name: "Minor Healing"
|
||||
effect_type: hot
|
||||
power: 30
|
||||
duration: 1
|
||||
stacks: 1
|
||||
|
||||
health_potion_medium:
|
||||
name: "Health Potion"
|
||||
item_type: consumable
|
||||
rarity: uncommon
|
||||
description: "A standard healing potion used by adventurers across the realm."
|
||||
value: 75
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: heal_medium
|
||||
name: "Healing"
|
||||
effect_type: hot
|
||||
power: 75
|
||||
duration: 1
|
||||
stacks: 1
|
||||
|
||||
health_potion_large:
|
||||
name: "Large Health Potion"
|
||||
item_type: consumable
|
||||
rarity: rare
|
||||
description: "A potent healing draught that restores significant health."
|
||||
value: 150
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: heal_large
|
||||
name: "Major Healing"
|
||||
effect_type: hot
|
||||
power: 150
|
||||
duration: 1
|
||||
stacks: 1
|
||||
|
||||
# ==========================================================================
|
||||
# Mana Potions
|
||||
# ==========================================================================
|
||||
|
||||
mana_potion_small:
|
||||
name: "Small Mana Potion"
|
||||
item_type: consumable
|
||||
rarity: common
|
||||
description: "A small vial of blue liquid that restores mana."
|
||||
value: 25
|
||||
is_tradeable: true
|
||||
# Note: MP restoration would need custom effect type or game logic
|
||||
|
||||
mana_potion_medium:
|
||||
name: "Mana Potion"
|
||||
item_type: consumable
|
||||
rarity: uncommon
|
||||
description: "A standard mana potion favored by spellcasters."
|
||||
value: 75
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Status Effect Cures
|
||||
# ==========================================================================
|
||||
|
||||
antidote:
|
||||
name: "Antidote"
|
||||
item_type: consumable
|
||||
rarity: common
|
||||
description: "A bitter herbal remedy that cures poison effects."
|
||||
value: 30
|
||||
is_tradeable: true
|
||||
|
||||
smelling_salts:
|
||||
name: "Smelling Salts"
|
||||
item_type: consumable
|
||||
rarity: uncommon
|
||||
description: "Pungent salts that can revive unconscious allies or cure stun."
|
||||
value: 40
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Combat Buffs
|
||||
# ==========================================================================
|
||||
|
||||
elixir_of_strength:
|
||||
name: "Elixir of Strength"
|
||||
item_type: consumable
|
||||
rarity: rare
|
||||
description: "A powerful elixir that temporarily increases strength."
|
||||
value: 100
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: str_buff
|
||||
name: "Strength Boost"
|
||||
effect_type: buff
|
||||
power: 5
|
||||
duration: 5
|
||||
stacks: 1
|
||||
|
||||
elixir_of_agility:
|
||||
name: "Elixir of Agility"
|
||||
item_type: consumable
|
||||
rarity: rare
|
||||
description: "A shimmering elixir that enhances reflexes and speed."
|
||||
value: 100
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: dex_buff
|
||||
name: "Agility Boost"
|
||||
effect_type: buff
|
||||
power: 5
|
||||
duration: 5
|
||||
stacks: 1
|
||||
|
||||
# ==========================================================================
|
||||
# Food Items (simple healing, no combat use)
|
||||
# ==========================================================================
|
||||
|
||||
ration:
|
||||
name: "Trail Ration"
|
||||
item_type: consumable
|
||||
rarity: common
|
||||
description: "Dried meat, hardtack, and nuts. Sustains an adventurer on long journeys."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: ration_heal
|
||||
name: "Nourishment"
|
||||
effect_type: hot
|
||||
power: 10
|
||||
duration: 1
|
||||
stacks: 1
|
||||
|
||||
cooked_meat:
|
||||
name: "Cooked Meat"
|
||||
item_type: consumable
|
||||
rarity: common
|
||||
description: "Freshly cooked meat that restores health."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
effects_on_use:
|
||||
- effect_id: meat_heal
|
||||
name: "Hearty Meal"
|
||||
effect_type: hot
|
||||
power: 20
|
||||
duration: 1
|
||||
stacks: 1
|
||||
207
api/app/data/static_items/materials.yaml
Normal file
207
api/app/data/static_items/materials.yaml
Normal file
@@ -0,0 +1,207 @@
|
||||
# Trophy items, crafting materials, and quest items dropped by enemies
|
||||
# These items don't have combat effects but are used for quests, crafting, or selling
|
||||
|
||||
items:
|
||||
# ==========================================================================
|
||||
# Goblin Drops
|
||||
# ==========================================================================
|
||||
|
||||
goblin_ear:
|
||||
name: "Goblin Ear"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A severed goblin ear. Proof of a kill, sometimes collected for bounties."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
|
||||
goblin_trinket:
|
||||
name: "Goblin Trinket"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A crude piece of jewelry stolen by a goblin. Worth a few coins."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
|
||||
goblin_war_paint:
|
||||
name: "Goblin War Paint"
|
||||
item_type: quest_item
|
||||
rarity: uncommon
|
||||
description: "Pungent red and black paint used by goblin warriors before battle."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
|
||||
goblin_chieftain_token:
|
||||
name: "Chieftain's Token"
|
||||
item_type: quest_item
|
||||
rarity: rare
|
||||
description: "A carved bone token marking the authority of a goblin chieftain."
|
||||
value: 50
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Wolf/Beast Drops
|
||||
# ==========================================================================
|
||||
|
||||
wolf_pelt:
|
||||
name: "Wolf Pelt"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A fur pelt from a wolf. Useful for crafting or selling to tanners."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
|
||||
dire_wolf_fang:
|
||||
name: "Dire Wolf Fang"
|
||||
item_type: quest_item
|
||||
rarity: uncommon
|
||||
description: "A large fang from a dire wolf. Prized by craftsmen for weapon making."
|
||||
value: 25
|
||||
is_tradeable: true
|
||||
|
||||
beast_hide:
|
||||
name: "Beast Hide"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "Thick hide from a large beast. Can be tanned into leather."
|
||||
value: 12
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Undead Drops
|
||||
# ==========================================================================
|
||||
|
||||
skeleton_bone:
|
||||
name: "Skeleton Bone"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A bone from an animated skeleton. Retains faint magical energy."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
|
||||
bone_dust:
|
||||
name: "Bone Dust"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "Powdered bone from undead remains. Used in alchemy and rituals."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
|
||||
skull_fragment:
|
||||
name: "Skull Fragment"
|
||||
item_type: quest_item
|
||||
rarity: uncommon
|
||||
description: "A piece of an undead skull, still crackling with dark energy."
|
||||
value: 20
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Orc Drops
|
||||
# ==========================================================================
|
||||
|
||||
orc_tusk:
|
||||
name: "Orc Tusk"
|
||||
item_type: quest_item
|
||||
rarity: uncommon
|
||||
description: "A large tusk from an orc warrior. A trophy prized by collectors."
|
||||
value: 25
|
||||
is_tradeable: true
|
||||
|
||||
orc_war_banner:
|
||||
name: "Orc War Banner"
|
||||
item_type: quest_item
|
||||
rarity: rare
|
||||
description: "A bloodstained banner torn from an orc warband. Proof of a hard fight."
|
||||
value: 45
|
||||
is_tradeable: true
|
||||
|
||||
berserker_charm:
|
||||
name: "Berserker Charm"
|
||||
item_type: quest_item
|
||||
rarity: rare
|
||||
description: "A crude charm worn by orc berserkers. Said to enhance rage."
|
||||
value: 60
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Bandit Drops
|
||||
# ==========================================================================
|
||||
|
||||
bandit_mask:
|
||||
name: "Bandit Mask"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A cloth mask worn by bandits to conceal their identity."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
|
||||
stolen_coin_pouch:
|
||||
name: "Stolen Coin Pouch"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A small pouch of coins stolen by bandits. Should be returned."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
|
||||
wanted_poster:
|
||||
name: "Wanted Poster"
|
||||
item_type: quest_item
|
||||
rarity: uncommon
|
||||
description: "A crumpled wanted poster. May lead to bounty opportunities."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Generic/Currency Items
|
||||
# ==========================================================================
|
||||
|
||||
gold_coin:
|
||||
name: "Gold Coin"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A single gold coin. Standard currency across the realm."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
|
||||
silver_coin:
|
||||
name: "Silver Coin"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "A silver coin worth less than gold but still useful."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
|
||||
# ==========================================================================
|
||||
# Crafting Materials (Generic)
|
||||
# ==========================================================================
|
||||
|
||||
iron_ore:
|
||||
name: "Iron Ore"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "Raw iron ore that can be smelted into ingots."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
|
||||
leather_scraps:
|
||||
name: "Leather Scraps"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "Scraps of leather useful for crafting and repairs."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
|
||||
cloth_scraps:
|
||||
name: "Cloth Scraps"
|
||||
item_type: quest_item
|
||||
rarity: common
|
||||
description: "Torn cloth that can be sewn into bandages or used for crafting."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
|
||||
magic_essence:
|
||||
name: "Magic Essence"
|
||||
item_type: quest_item
|
||||
rarity: uncommon
|
||||
description: "Crystallized magical energy. Used in enchanting and alchemy."
|
||||
value: 30
|
||||
is_tradeable: true
|
||||
@@ -21,35 +21,92 @@ class EnemyDifficulty(Enum):
|
||||
BOSS = "boss"
|
||||
|
||||
|
||||
class LootType(Enum):
|
||||
"""
|
||||
Types of loot drops in enemy loot tables.
|
||||
|
||||
STATIC: Fixed item_id reference (consumables, quest items, materials)
|
||||
PROCEDURAL: Procedurally generated equipment (weapons, armor with affixes)
|
||||
"""
|
||||
STATIC = "static"
|
||||
PROCEDURAL = "procedural"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LootEntry:
|
||||
"""
|
||||
Single entry in an enemy's loot table.
|
||||
|
||||
Supports two types of loot:
|
||||
|
||||
STATIC loot (default):
|
||||
- item_id references a predefined item (health_potion, gold_coin, etc.)
|
||||
- quantity_min/max define stack size
|
||||
|
||||
PROCEDURAL loot:
|
||||
- item_type specifies "weapon" or "armor"
|
||||
- rarity_bonus adds to rarity roll (difficulty contribution)
|
||||
- Generated equipment uses the ItemGenerator system
|
||||
|
||||
Attributes:
|
||||
item_id: Reference to item definition
|
||||
loot_type: Type of loot (static or procedural)
|
||||
drop_chance: Probability of dropping (0.0 to 1.0)
|
||||
quantity_min: Minimum quantity if dropped
|
||||
quantity_max: Maximum quantity if dropped
|
||||
item_id: Reference to item definition (for STATIC loot)
|
||||
item_type: Type of equipment to generate (for PROCEDURAL loot)
|
||||
rarity_bonus: Added to rarity roll (0.0 to 0.5, for PROCEDURAL)
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
# Common fields
|
||||
loot_type: LootType = LootType.STATIC
|
||||
drop_chance: float = 0.1
|
||||
quantity_min: int = 1
|
||||
quantity_max: int = 1
|
||||
|
||||
# Static loot fields
|
||||
item_id: Optional[str] = None
|
||||
|
||||
# Procedural loot fields
|
||||
item_type: Optional[str] = None # "weapon" or "armor"
|
||||
rarity_bonus: float = 0.0 # Added to rarity roll (0.0 to 0.5)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize loot entry to dictionary."""
|
||||
return asdict(self)
|
||||
data = {
|
||||
"loot_type": self.loot_type.value,
|
||||
"drop_chance": self.drop_chance,
|
||||
"quantity_min": self.quantity_min,
|
||||
"quantity_max": self.quantity_max,
|
||||
}
|
||||
# Only include relevant fields based on loot type
|
||||
if self.item_id is not None:
|
||||
data["item_id"] = self.item_id
|
||||
if self.item_type is not None:
|
||||
data["item_type"] = self.item_type
|
||||
data["rarity_bonus"] = self.rarity_bonus
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry':
|
||||
"""Deserialize loot entry from dictionary."""
|
||||
"""
|
||||
Deserialize loot entry from dictionary.
|
||||
|
||||
Backward compatible: entries without loot_type default to STATIC,
|
||||
and item_id is required for STATIC entries (for backward compat).
|
||||
"""
|
||||
# Parse loot type with backward compatibility
|
||||
loot_type_str = data.get("loot_type", "static")
|
||||
loot_type = LootType(loot_type_str)
|
||||
|
||||
return cls(
|
||||
item_id=data["item_id"],
|
||||
loot_type=loot_type,
|
||||
drop_chance=data.get("drop_chance", 0.1),
|
||||
quantity_min=data.get("quantity_min", 1),
|
||||
quantity_max=data.get("quantity_max", 1),
|
||||
item_id=data.get("item_id"),
|
||||
item_type=data.get("item_type"),
|
||||
rarity_bonus=data.get("rarity_bonus", 0.0),
|
||||
)
|
||||
|
||||
|
||||
|
||||
359
api/app/services/combat_loot_service.py
Normal file
359
api/app/services/combat_loot_service.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Combat Loot Service - Orchestrates loot generation from combat encounters.
|
||||
|
||||
This service bridges the EnemyTemplate loot tables with both the StaticItemLoader
|
||||
(for consumables and materials) and ItemGenerator (for procedural equipment).
|
||||
|
||||
The service calculates effective rarity based on:
|
||||
- Party average level
|
||||
- Enemy difficulty tier
|
||||
- Character luck stat
|
||||
- Optional loot bonus modifiers (from abilities, buffs, etc.)
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from app.models.enemy import EnemyTemplate, LootEntry, LootType, EnemyDifficulty
|
||||
from app.models.items import Item
|
||||
from app.services.item_generator import get_item_generator, ItemGenerator
|
||||
from app.services.static_item_loader import get_static_item_loader, StaticItemLoader
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Difficulty tier rarity bonuses (converted to effective luck points)
|
||||
# Higher difficulty enemies have better chances of dropping rare items
|
||||
DIFFICULTY_RARITY_BONUS = {
|
||||
EnemyDifficulty.EASY: 0.0,
|
||||
EnemyDifficulty.MEDIUM: 0.05,
|
||||
EnemyDifficulty.HARD: 0.15,
|
||||
EnemyDifficulty.BOSS: 0.30,
|
||||
}
|
||||
|
||||
# Multiplier for converting rarity bonus to effective luck points
|
||||
# Each 0.05 bonus translates to +1 effective luck
|
||||
LUCK_CONVERSION_FACTOR = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class LootContext:
|
||||
"""
|
||||
Context for loot generation calculations.
|
||||
|
||||
Provides all the factors that influence loot quality and rarity.
|
||||
|
||||
Attributes:
|
||||
party_average_level: Average level of player characters in the encounter
|
||||
enemy_difficulty: Difficulty tier of the enemy being looted
|
||||
luck_stat: Party's luck stat (typically average or leader's luck)
|
||||
loot_bonus: Additional bonus from abilities, buffs, or modifiers (0.0 to 1.0)
|
||||
"""
|
||||
party_average_level: int = 1
|
||||
enemy_difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
||||
luck_stat: int = 8
|
||||
loot_bonus: float = 0.0
|
||||
|
||||
|
||||
class CombatLootService:
|
||||
"""
|
||||
Service for generating combat loot drops.
|
||||
|
||||
Supports two types of loot:
|
||||
- STATIC: Predefined items loaded from YAML (consumables, materials)
|
||||
- PROCEDURAL: Generated equipment with affixes (weapons, armor)
|
||||
|
||||
The service handles:
|
||||
- Rolling for drops based on drop_chance
|
||||
- Loading static items via StaticItemLoader
|
||||
- Generating procedural items via ItemGenerator
|
||||
- Calculating effective rarity based on context
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
item_generator: Optional[ItemGenerator] = None,
|
||||
static_loader: Optional[StaticItemLoader] = None
|
||||
):
|
||||
"""
|
||||
Initialize the combat loot service.
|
||||
|
||||
Args:
|
||||
item_generator: ItemGenerator instance (uses global singleton if None)
|
||||
static_loader: StaticItemLoader instance (uses global singleton if None)
|
||||
"""
|
||||
self.item_generator = item_generator or get_item_generator()
|
||||
self.static_loader = static_loader or get_static_item_loader()
|
||||
logger.info("CombatLootService initialized")
|
||||
|
||||
def generate_loot_from_enemy(
|
||||
self,
|
||||
enemy: EnemyTemplate,
|
||||
context: LootContext
|
||||
) -> List[Item]:
|
||||
"""
|
||||
Generate all loot drops from a defeated enemy.
|
||||
|
||||
Iterates through the enemy's loot table, rolling for each entry
|
||||
and generating appropriate items based on loot type.
|
||||
|
||||
Args:
|
||||
enemy: The defeated enemy template
|
||||
context: Loot generation context (party level, luck, etc.)
|
||||
|
||||
Returns:
|
||||
List of Item objects to add to player inventory
|
||||
"""
|
||||
items = []
|
||||
|
||||
for entry in enemy.loot_table:
|
||||
# Roll for drop chance
|
||||
if random.random() >= entry.drop_chance:
|
||||
continue
|
||||
|
||||
# Determine quantity
|
||||
quantity = random.randint(entry.quantity_min, entry.quantity_max)
|
||||
|
||||
if entry.loot_type == LootType.STATIC:
|
||||
# Static item: load from predefined templates
|
||||
static_items = self._generate_static_items(entry, quantity)
|
||||
items.extend(static_items)
|
||||
|
||||
elif entry.loot_type == LootType.PROCEDURAL:
|
||||
# Procedural equipment: generate with ItemGenerator
|
||||
procedural_items = self._generate_procedural_items(
|
||||
entry, quantity, context
|
||||
)
|
||||
items.extend(procedural_items)
|
||||
|
||||
logger.info(
|
||||
"Loot generated from enemy",
|
||||
enemy_id=enemy.enemy_id,
|
||||
enemy_difficulty=enemy.difficulty.value,
|
||||
item_count=len(items),
|
||||
party_level=context.party_average_level,
|
||||
luck=context.luck_stat
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def _generate_static_items(
|
||||
self,
|
||||
entry: LootEntry,
|
||||
quantity: int
|
||||
) -> List[Item]:
|
||||
"""
|
||||
Generate static items from a loot entry.
|
||||
|
||||
Args:
|
||||
entry: The loot table entry
|
||||
quantity: Number of items to generate
|
||||
|
||||
Returns:
|
||||
List of Item instances
|
||||
"""
|
||||
items = []
|
||||
|
||||
if not entry.item_id:
|
||||
logger.warning(
|
||||
"Static loot entry missing item_id",
|
||||
entry=entry.to_dict()
|
||||
)
|
||||
return items
|
||||
|
||||
for _ in range(quantity):
|
||||
item = self.static_loader.get_item(entry.item_id)
|
||||
if item:
|
||||
items.append(item)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to load static item",
|
||||
item_id=entry.item_id
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def _generate_procedural_items(
|
||||
self,
|
||||
entry: LootEntry,
|
||||
quantity: int,
|
||||
context: LootContext
|
||||
) -> List[Item]:
|
||||
"""
|
||||
Generate procedural items from a loot entry.
|
||||
|
||||
Calculates effective luck based on:
|
||||
- Base luck stat
|
||||
- Entry-specific rarity bonus
|
||||
- Difficulty bonus
|
||||
- Loot bonus from abilities/buffs
|
||||
|
||||
Args:
|
||||
entry: The loot table entry
|
||||
quantity: Number of items to generate
|
||||
context: Loot generation context
|
||||
|
||||
Returns:
|
||||
List of generated Item instances
|
||||
"""
|
||||
items = []
|
||||
|
||||
if not entry.item_type:
|
||||
logger.warning(
|
||||
"Procedural loot entry missing item_type",
|
||||
entry=entry.to_dict()
|
||||
)
|
||||
return items
|
||||
|
||||
# Calculate effective luck for rarity roll
|
||||
effective_luck = self._calculate_effective_luck(entry, context)
|
||||
|
||||
for _ in range(quantity):
|
||||
item = self.item_generator.generate_loot_drop(
|
||||
character_level=context.party_average_level,
|
||||
luck_stat=effective_luck,
|
||||
item_type=entry.item_type
|
||||
)
|
||||
if item:
|
||||
items.append(item)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to generate procedural item",
|
||||
item_type=entry.item_type,
|
||||
level=context.party_average_level
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def _calculate_effective_luck(
|
||||
self,
|
||||
entry: LootEntry,
|
||||
context: LootContext
|
||||
) -> int:
|
||||
"""
|
||||
Calculate effective luck for rarity rolling.
|
||||
|
||||
Combines multiple factors:
|
||||
- Base luck stat from party
|
||||
- Entry-specific rarity bonus (defined per loot entry)
|
||||
- Difficulty bonus (based on enemy tier)
|
||||
- Loot bonus (from abilities, buffs, etc.)
|
||||
|
||||
The formula:
|
||||
effective_luck = base_luck + (entry_bonus + difficulty_bonus + loot_bonus) * FACTOR
|
||||
|
||||
Args:
|
||||
entry: The loot table entry
|
||||
context: Loot generation context
|
||||
|
||||
Returns:
|
||||
Effective luck stat for rarity calculations
|
||||
"""
|
||||
# Get difficulty bonus
|
||||
difficulty_bonus = DIFFICULTY_RARITY_BONUS.get(
|
||||
context.enemy_difficulty, 0.0
|
||||
)
|
||||
|
||||
# Sum all bonuses
|
||||
total_bonus = (
|
||||
entry.rarity_bonus +
|
||||
difficulty_bonus +
|
||||
context.loot_bonus
|
||||
)
|
||||
|
||||
# Convert bonus to effective luck points
|
||||
bonus_luck = int(total_bonus * LUCK_CONVERSION_FACTOR)
|
||||
|
||||
effective_luck = context.luck_stat + bonus_luck
|
||||
|
||||
logger.debug(
|
||||
"Effective luck calculated",
|
||||
base_luck=context.luck_stat,
|
||||
entry_bonus=entry.rarity_bonus,
|
||||
difficulty_bonus=difficulty_bonus,
|
||||
loot_bonus=context.loot_bonus,
|
||||
total_bonus=total_bonus,
|
||||
effective_luck=effective_luck
|
||||
)
|
||||
|
||||
return effective_luck
|
||||
|
||||
def generate_boss_loot(
|
||||
self,
|
||||
enemy: EnemyTemplate,
|
||||
context: LootContext,
|
||||
guaranteed_drops: int = 1
|
||||
) -> List[Item]:
|
||||
"""
|
||||
Generate loot from a boss enemy with guaranteed drops.
|
||||
|
||||
Boss enemies are guaranteed to drop at least one piece of equipment
|
||||
in addition to their normal loot table rolls.
|
||||
|
||||
Args:
|
||||
enemy: The boss enemy template
|
||||
context: Loot generation context
|
||||
guaranteed_drops: Number of guaranteed equipment drops
|
||||
|
||||
Returns:
|
||||
List of Item objects including guaranteed drops
|
||||
"""
|
||||
# Generate normal loot first
|
||||
items = self.generate_loot_from_enemy(enemy, context)
|
||||
|
||||
# Add guaranteed procedural drops for bosses
|
||||
if enemy.is_boss():
|
||||
context_for_boss = LootContext(
|
||||
party_average_level=context.party_average_level,
|
||||
enemy_difficulty=EnemyDifficulty.BOSS,
|
||||
luck_stat=context.luck_stat,
|
||||
loot_bonus=context.loot_bonus + 0.1 # Extra bonus for bosses
|
||||
)
|
||||
|
||||
for _ in range(guaranteed_drops):
|
||||
# Alternate between weapon and armor
|
||||
item_type = random.choice(["weapon", "armor"])
|
||||
effective_luck = self._calculate_effective_luck(
|
||||
LootEntry(
|
||||
loot_type=LootType.PROCEDURAL,
|
||||
item_type=item_type,
|
||||
rarity_bonus=0.15 # Boss-tier bonus
|
||||
),
|
||||
context_for_boss
|
||||
)
|
||||
|
||||
item = self.item_generator.generate_loot_drop(
|
||||
character_level=context.party_average_level,
|
||||
luck_stat=effective_luck,
|
||||
item_type=item_type
|
||||
)
|
||||
if item:
|
||||
items.append(item)
|
||||
|
||||
logger.info(
|
||||
"Boss loot generated",
|
||||
enemy_id=enemy.enemy_id,
|
||||
guaranteed_drops=guaranteed_drops,
|
||||
total_items=len(items)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
# Global singleton
|
||||
_service_instance: Optional[CombatLootService] = None
|
||||
|
||||
|
||||
def get_combat_loot_service() -> CombatLootService:
|
||||
"""
|
||||
Get the global CombatLootService instance.
|
||||
|
||||
Returns:
|
||||
Singleton CombatLootService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = CombatLootService()
|
||||
return _service_instance
|
||||
@@ -15,7 +15,7 @@ from uuid import uuid4
|
||||
|
||||
from app.models.combat import Combatant, CombatEncounter
|
||||
from app.models.character import Character
|
||||
from app.models.enemy import EnemyTemplate
|
||||
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||
from app.models.stats import Stats
|
||||
from app.models.abilities import Ability, AbilityLoader
|
||||
from app.models.effects import Effect
|
||||
@@ -25,6 +25,11 @@ from app.services.damage_calculator import DamageCalculator, DamageResult
|
||||
from app.services.enemy_loader import EnemyLoader, get_enemy_loader
|
||||
from app.services.session_service import get_session_service
|
||||
from app.services.character_service import get_character_service
|
||||
from app.services.combat_loot_service import (
|
||||
get_combat_loot_service,
|
||||
CombatLootService,
|
||||
LootContext
|
||||
)
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -197,6 +202,7 @@ class CombatService:
|
||||
self.character_service = get_character_service()
|
||||
self.enemy_loader = get_enemy_loader()
|
||||
self.ability_loader = AbilityLoader()
|
||||
self.loot_service = get_combat_loot_service()
|
||||
|
||||
logger.info("CombatService initialized")
|
||||
|
||||
@@ -898,6 +904,9 @@ class CombatService:
|
||||
"""
|
||||
Calculate and distribute rewards after victory.
|
||||
|
||||
Uses CombatLootService for loot generation, supporting both
|
||||
static items (consumables) and procedural equipment.
|
||||
|
||||
Args:
|
||||
encounter: Completed combat encounter
|
||||
session: Game session
|
||||
@@ -908,6 +917,9 @@ class CombatService:
|
||||
"""
|
||||
rewards = CombatRewards()
|
||||
|
||||
# Build loot context from encounter
|
||||
loot_context = self._build_loot_context(encounter)
|
||||
|
||||
# Sum up rewards from defeated enemies
|
||||
for combatant in encounter.combatants:
|
||||
if not combatant.is_player and combatant.is_dead():
|
||||
@@ -919,9 +931,28 @@ class CombatService:
|
||||
rewards.experience += enemy.experience_reward
|
||||
rewards.gold += enemy.get_gold_reward()
|
||||
|
||||
# Roll for loot
|
||||
loot = enemy.roll_loot()
|
||||
rewards.items.extend(loot)
|
||||
# Generate loot using the loot service
|
||||
# Update context with this specific enemy's difficulty
|
||||
enemy_context = LootContext(
|
||||
party_average_level=loot_context.party_average_level,
|
||||
enemy_difficulty=enemy.difficulty,
|
||||
luck_stat=loot_context.luck_stat,
|
||||
loot_bonus=loot_context.loot_bonus
|
||||
)
|
||||
|
||||
# Use boss loot for boss enemies
|
||||
if enemy.is_boss():
|
||||
loot_items = self.loot_service.generate_boss_loot(
|
||||
enemy, enemy_context
|
||||
)
|
||||
else:
|
||||
loot_items = self.loot_service.generate_loot_from_enemy(
|
||||
enemy, enemy_context
|
||||
)
|
||||
|
||||
# Convert Item objects to dicts for serialization
|
||||
for item in loot_items:
|
||||
rewards.items.append(item.to_dict())
|
||||
|
||||
# Distribute rewards to player characters
|
||||
player_combatants = [c for c in encounter.combatants if c.is_player]
|
||||
@@ -964,6 +995,49 @@ class CombatService:
|
||||
|
||||
return rewards
|
||||
|
||||
def _build_loot_context(self, encounter: CombatEncounter) -> LootContext:
|
||||
"""
|
||||
Build loot generation context from a combat encounter.
|
||||
|
||||
Calculates:
|
||||
- Party average level
|
||||
- Party average luck stat
|
||||
- Default difficulty (uses EASY, specific enemies override)
|
||||
|
||||
Args:
|
||||
encounter: Combat encounter with player combatants
|
||||
|
||||
Returns:
|
||||
LootContext for loot generation
|
||||
"""
|
||||
player_combatants = [c for c in encounter.combatants if c.is_player]
|
||||
|
||||
# Calculate party average level
|
||||
if player_combatants:
|
||||
# Use combatant's level if available, otherwise default to 1
|
||||
levels = []
|
||||
for p in player_combatants:
|
||||
# Try to get level from stats or combatant
|
||||
level = getattr(p, 'level', 1)
|
||||
levels.append(level)
|
||||
avg_level = sum(levels) // len(levels) if levels else 1
|
||||
else:
|
||||
avg_level = 1
|
||||
|
||||
# Calculate party average luck
|
||||
if player_combatants:
|
||||
luck_values = [p.stats.luck for p in player_combatants]
|
||||
avg_luck = sum(luck_values) // len(luck_values) if luck_values else 8
|
||||
else:
|
||||
avg_luck = 8
|
||||
|
||||
return LootContext(
|
||||
party_average_level=avg_level,
|
||||
enemy_difficulty=EnemyDifficulty.EASY, # Default; overridden per-enemy
|
||||
luck_stat=avg_luck,
|
||||
loot_bonus=0.0 # Future: add buffs/abilities bonus
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
|
||||
276
api/app/services/static_item_loader.py
Normal file
276
api/app/services/static_item_loader.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
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
|
||||
428
api/tests/test_combat_loot_service.py
Normal file
428
api/tests/test_combat_loot_service.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
224
api/tests/test_loot_entry.py
Normal file
224
api/tests/test_loot_entry.py
Normal 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
|
||||
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
|
||||
@@ -1757,37 +1757,69 @@ base_damage = attacker_stats.spell_power + ability_base_power # Magical
|
||||
|
||||
---
|
||||
|
||||
### Future Work: Combat Loot Integration
|
||||
### Task 2.7: Combat Loot Integration ✅ COMPLETE
|
||||
|
||||
**Status:** Planned for future phase
|
||||
**Status:** Complete
|
||||
|
||||
The ItemGenerator is ready for integration with combat loot drops. Future implementation will:
|
||||
Integrated the ItemGenerator with combat loot drops via a hybrid loot system supporting both static and procedural drops.
|
||||
|
||||
**1. Update Enemy Loot Tables** - Add procedural generation options:
|
||||
**Implementation Summary:**
|
||||
|
||||
**1. Extended LootEntry Model** (`app/models/enemy.py`):
|
||||
```yaml
|
||||
# Example enhanced enemy loot entry
|
||||
# New hybrid loot table format
|
||||
loot_table:
|
||||
- type: "static"
|
||||
- loot_type: "static"
|
||||
item_id: "health_potion_small"
|
||||
drop_chance: 0.5
|
||||
- type: "generated"
|
||||
- loot_type: "procedural"
|
||||
item_type: "weapon"
|
||||
rarity_range: ["rare", "epic"]
|
||||
rarity_bonus: 0.10
|
||||
drop_chance: 0.1
|
||||
```
|
||||
|
||||
**2. Integrate with CombatService._calculate_rewards()** - Use ItemGenerator for loot rolls
|
||||
**2. Created CombatLootService** (`app/services/combat_loot_service.py`):
|
||||
- Orchestrates loot generation from combat encounters
|
||||
- Combines StaticItemLoader (consumables) + ItemGenerator (equipment)
|
||||
- Full rarity formula: `effective_luck = base_luck + (entry_bonus + difficulty_bonus + loot_bonus) * 20`
|
||||
|
||||
**3. Boss Guaranteed Drops** - Higher-tier enemies guarantee better rarity
|
||||
**3. Created StaticItemLoader** (`app/services/static_item_loader.py`):
|
||||
- Loads predefined items from `app/data/static_items/` YAML files
|
||||
- Supports consumables, materials, and quest items
|
||||
|
||||
**4. Luck Stat Integration** - Player luck affects all loot rolls
|
||||
**4. Integrated with CombatService._calculate_rewards()**:
|
||||
- Builds `LootContext` from encounter (party level, luck, difficulty)
|
||||
- Calls `CombatLootService.generate_loot_from_enemy()` for each defeated enemy
|
||||
- Boss enemies get guaranteed equipment drops via `generate_boss_loot()`
|
||||
|
||||
**Implementation Notes:**
|
||||
- Current enemy loot tables use `item_id` references (static items only)
|
||||
- ItemGenerator provides `generate_loot_drop(character_level, luck_stat)` method
|
||||
- Generated items must be stored as full objects (not IDs) in character inventory
|
||||
- Consider adding `LootService` wrapper for consistent loot generation across all sources
|
||||
**5. Difficulty Rarity Bonuses:**
|
||||
- EASY: +0% | MEDIUM: +5% | HARD: +15% | BOSS: +30%
|
||||
|
||||
**6. Enemy Variants Created** (proof-of-concept):
|
||||
- `goblin_scout.yaml` (Easy) - static drops only
|
||||
- `goblin_warrior.yaml` (Medium) - static + 8% procedural weapon
|
||||
- `goblin_chieftain.yaml` (Hard) - static + 25% weapon, 15% armor
|
||||
|
||||
**Files Created:**
|
||||
- `app/services/combat_loot_service.py`
|
||||
- `app/services/static_item_loader.py`
|
||||
- `app/data/static_items/consumables.yaml`
|
||||
- `app/data/static_items/materials.yaml`
|
||||
- `app/data/enemies/goblin_scout.yaml`
|
||||
- `app/data/enemies/goblin_warrior.yaml`
|
||||
- `app/data/enemies/goblin_chieftain.yaml`
|
||||
- `tests/test_loot_entry.py` (16 tests)
|
||||
- `tests/test_static_item_loader.py` (19 tests)
|
||||
- `tests/test_combat_loot_service.py` (24 tests)
|
||||
|
||||
**Checklist:**
|
||||
- [x] LootType enum and extended LootEntry (backward compatible)
|
||||
- [x] StaticItemLoader service for consumables/materials
|
||||
- [x] CombatLootService with full rarity formula
|
||||
- [x] CombatService integration with `_build_loot_context()`
|
||||
- [x] Static items YAML files (consumables, materials)
|
||||
- [x] Goblin variant YAML files (scout, warrior, chieftain)
|
||||
- [x] Unit tests (59 new tests passing)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user