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

View 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

View 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

View 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

View 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

View File

@@ -21,35 +21,92 @@ class EnemyDifficulty(Enum):
BOSS = "boss" 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 @dataclass
class LootEntry: class LootEntry:
""" """
Single entry in an enemy's loot table. 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: 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) drop_chance: Probability of dropping (0.0 to 1.0)
quantity_min: Minimum quantity if dropped quantity_min: Minimum quantity if dropped
quantity_max: Maximum 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 drop_chance: float = 0.1
quantity_min: int = 1 quantity_min: int = 1
quantity_max: 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]: def to_dict(self) -> Dict[str, Any]:
"""Serialize loot entry to dictionary.""" """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 @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry': 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( return cls(
item_id=data["item_id"], loot_type=loot_type,
drop_chance=data.get("drop_chance", 0.1), drop_chance=data.get("drop_chance", 0.1),
quantity_min=data.get("quantity_min", 1), quantity_min=data.get("quantity_min", 1),
quantity_max=data.get("quantity_max", 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),
) )

View 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

View File

@@ -15,7 +15,7 @@ from uuid import uuid4
from app.models.combat import Combatant, CombatEncounter from app.models.combat import Combatant, CombatEncounter
from app.models.character import Character 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.stats import Stats
from app.models.abilities import Ability, AbilityLoader from app.models.abilities import Ability, AbilityLoader
from app.models.effects import Effect 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.enemy_loader import EnemyLoader, get_enemy_loader
from app.services.session_service import get_session_service from app.services.session_service import get_session_service
from app.services.character_service import get_character_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 from app.utils.logging import get_logger
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -197,6 +202,7 @@ class CombatService:
self.character_service = get_character_service() self.character_service = get_character_service()
self.enemy_loader = get_enemy_loader() self.enemy_loader = get_enemy_loader()
self.ability_loader = AbilityLoader() self.ability_loader = AbilityLoader()
self.loot_service = get_combat_loot_service()
logger.info("CombatService initialized") logger.info("CombatService initialized")
@@ -898,6 +904,9 @@ class CombatService:
""" """
Calculate and distribute rewards after victory. Calculate and distribute rewards after victory.
Uses CombatLootService for loot generation, supporting both
static items (consumables) and procedural equipment.
Args: Args:
encounter: Completed combat encounter encounter: Completed combat encounter
session: Game session session: Game session
@@ -908,6 +917,9 @@ class CombatService:
""" """
rewards = CombatRewards() rewards = CombatRewards()
# Build loot context from encounter
loot_context = self._build_loot_context(encounter)
# Sum up rewards from defeated enemies # Sum up rewards from defeated enemies
for combatant in encounter.combatants: for combatant in encounter.combatants:
if not combatant.is_player and combatant.is_dead(): if not combatant.is_player and combatant.is_dead():
@@ -919,9 +931,28 @@ class CombatService:
rewards.experience += enemy.experience_reward rewards.experience += enemy.experience_reward
rewards.gold += enemy.get_gold_reward() rewards.gold += enemy.get_gold_reward()
# Roll for loot # Generate loot using the loot service
loot = enemy.roll_loot() # Update context with this specific enemy's difficulty
rewards.items.extend(loot) 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 # Distribute rewards to player characters
player_combatants = [c for c in encounter.combatants if c.is_player] player_combatants = [c for c in encounter.combatants if c.is_player]
@@ -964,6 +995,49 @@ class CombatService:
return rewards 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 # Helper Methods
# ========================================================================= # =========================================================================

View 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

View File

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

View File

@@ -623,14 +623,22 @@ class TestRewardsCalculation:
service = CombatService.__new__(CombatService) service = CombatService.__new__(CombatService)
service.enemy_loader = Mock() service.enemy_loader = Mock()
service.character_service = Mock() service.character_service = Mock()
service.loot_service = Mock()
# Mock enemy template for rewards # Mock enemy template for rewards
mock_template = Mock() mock_template = Mock()
mock_template.experience_reward = 50 mock_template.experience_reward = 50
mock_template.get_gold_reward.return_value = 25 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 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 = Mock()
mock_session.is_solo.return_value = True mock_session.is_solo.return_value = True
mock_session.solo_character_id = "test_char" mock_session.solo_character_id = "test_char"

View File

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

View File

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

View File

@@ -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 ```yaml
# Example enhanced enemy loot entry # New hybrid loot table format
loot_table: loot_table:
- type: "static" - loot_type: "static"
item_id: "health_potion_small" item_id: "health_potion_small"
drop_chance: 0.5 drop_chance: 0.5
- type: "generated" - loot_type: "procedural"
item_type: "weapon" item_type: "weapon"
rarity_range: ["rare", "epic"] rarity_bonus: 0.10
drop_chance: 0.1 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:** **5. Difficulty Rarity Bonuses:**
- Current enemy loot tables use `item_id` references (static items only) - EASY: +0% | MEDIUM: +5% | HARD: +15% | BOSS: +30%
- ItemGenerator provides `generate_loot_drop(character_level, luck_stat)` method
- Generated items must be stored as full objects (not IDs) in character inventory **6. Enemy Variants Created** (proof-of-concept):
- Consider adding `LootService` wrapper for consistent loot generation across all sources - `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)
--- ---