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)
360 lines
11 KiB
Python
360 lines
11 KiB
Python
"""
|
|
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
|