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

@@ -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
# =========================================================================