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:
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
|
||||
Reference in New Issue
Block a user