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)
275 lines
8.9 KiB
Python
275 lines
8.9 KiB
Python
"""
|
|
Enemy data models for combat encounters.
|
|
|
|
This module defines the EnemyTemplate dataclass representing enemies/monsters
|
|
that can be encountered in combat. Enemy definitions are loaded from YAML files
|
|
for data-driven game design.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
from enum import Enum
|
|
|
|
from app.models.stats import Stats
|
|
|
|
|
|
class EnemyDifficulty(Enum):
|
|
"""Enemy difficulty levels for scaling and encounter building."""
|
|
EASY = "easy"
|
|
MEDIUM = "medium"
|
|
HARD = "hard"
|
|
BOSS = "boss"
|
|
|
|
|
|
class LootType(Enum):
|
|
"""
|
|
Types of loot drops in enemy loot tables.
|
|
|
|
STATIC: Fixed item_id reference (consumables, quest items, materials)
|
|
PROCEDURAL: Procedurally generated equipment (weapons, armor with affixes)
|
|
"""
|
|
STATIC = "static"
|
|
PROCEDURAL = "procedural"
|
|
|
|
|
|
@dataclass
|
|
class LootEntry:
|
|
"""
|
|
Single entry in an enemy's loot table.
|
|
|
|
Supports two types of loot:
|
|
|
|
STATIC loot (default):
|
|
- item_id references a predefined item (health_potion, gold_coin, etc.)
|
|
- quantity_min/max define stack size
|
|
|
|
PROCEDURAL loot:
|
|
- item_type specifies "weapon" or "armor"
|
|
- rarity_bonus adds to rarity roll (difficulty contribution)
|
|
- Generated equipment uses the ItemGenerator system
|
|
|
|
Attributes:
|
|
loot_type: Type of loot (static or procedural)
|
|
drop_chance: Probability of dropping (0.0 to 1.0)
|
|
quantity_min: Minimum quantity if dropped
|
|
quantity_max: Maximum quantity if dropped
|
|
item_id: Reference to item definition (for STATIC loot)
|
|
item_type: Type of equipment to generate (for PROCEDURAL loot)
|
|
rarity_bonus: Added to rarity roll (0.0 to 0.5, for PROCEDURAL)
|
|
"""
|
|
|
|
# Common fields
|
|
loot_type: LootType = LootType.STATIC
|
|
drop_chance: float = 0.1
|
|
quantity_min: int = 1
|
|
quantity_max: int = 1
|
|
|
|
# Static loot fields
|
|
item_id: Optional[str] = None
|
|
|
|
# Procedural loot fields
|
|
item_type: Optional[str] = None # "weapon" or "armor"
|
|
rarity_bonus: float = 0.0 # Added to rarity roll (0.0 to 0.5)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Serialize loot entry to dictionary."""
|
|
data = {
|
|
"loot_type": self.loot_type.value,
|
|
"drop_chance": self.drop_chance,
|
|
"quantity_min": self.quantity_min,
|
|
"quantity_max": self.quantity_max,
|
|
}
|
|
# Only include relevant fields based on loot type
|
|
if self.item_id is not None:
|
|
data["item_id"] = self.item_id
|
|
if self.item_type is not None:
|
|
data["item_type"] = self.item_type
|
|
data["rarity_bonus"] = self.rarity_bonus
|
|
return data
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry':
|
|
"""
|
|
Deserialize loot entry from dictionary.
|
|
|
|
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(
|
|
loot_type=loot_type,
|
|
drop_chance=data.get("drop_chance", 0.1),
|
|
quantity_min=data.get("quantity_min", 1),
|
|
quantity_max=data.get("quantity_max", 1),
|
|
item_id=data.get("item_id"),
|
|
item_type=data.get("item_type"),
|
|
rarity_bonus=data.get("rarity_bonus", 0.0),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class EnemyTemplate:
|
|
"""
|
|
Template definition for an enemy type.
|
|
|
|
EnemyTemplates define the base characteristics of enemy types. When combat
|
|
starts, instances are created from templates with randomized variations.
|
|
|
|
Attributes:
|
|
enemy_id: Unique identifier (e.g., "goblin", "dire_wolf")
|
|
name: Display name (e.g., "Goblin Scout")
|
|
description: Flavor text about the enemy
|
|
base_stats: Base stat block for this enemy
|
|
abilities: List of ability_ids this enemy can use
|
|
loot_table: Potential drops on defeat
|
|
experience_reward: Base XP granted on defeat
|
|
gold_reward_min: Minimum gold dropped
|
|
gold_reward_max: Maximum gold dropped
|
|
difficulty: Difficulty classification for encounter building
|
|
tags: Classification tags (e.g., ["humanoid", "goblinoid"])
|
|
image_url: Optional image reference for UI
|
|
|
|
Combat-specific attributes:
|
|
base_damage: Base damage for basic attack (no weapon)
|
|
crit_chance: Critical hit chance (0.0 to 1.0)
|
|
flee_chance: Chance to successfully flee from this enemy
|
|
"""
|
|
|
|
enemy_id: str
|
|
name: str
|
|
description: str
|
|
base_stats: Stats
|
|
abilities: List[str] = field(default_factory=list)
|
|
loot_table: List[LootEntry] = field(default_factory=list)
|
|
experience_reward: int = 10
|
|
gold_reward_min: int = 1
|
|
gold_reward_max: int = 5
|
|
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
|
tags: List[str] = field(default_factory=list)
|
|
image_url: Optional[str] = None
|
|
|
|
# Combat attributes
|
|
base_damage: int = 5
|
|
crit_chance: float = 0.05
|
|
flee_chance: float = 0.5
|
|
|
|
def get_gold_reward(self) -> int:
|
|
"""
|
|
Roll random gold reward within range.
|
|
|
|
Returns:
|
|
Random gold amount between min and max
|
|
"""
|
|
import random
|
|
return random.randint(self.gold_reward_min, self.gold_reward_max)
|
|
|
|
def roll_loot(self) -> List[Dict[str, Any]]:
|
|
"""
|
|
Roll for loot drops based on loot table.
|
|
|
|
Returns:
|
|
List of dropped items with quantities
|
|
"""
|
|
import random
|
|
drops = []
|
|
|
|
for entry in self.loot_table:
|
|
if random.random() < entry.drop_chance:
|
|
quantity = random.randint(entry.quantity_min, entry.quantity_max)
|
|
drops.append({
|
|
"item_id": entry.item_id,
|
|
"quantity": quantity,
|
|
})
|
|
|
|
return drops
|
|
|
|
def is_boss(self) -> bool:
|
|
"""Check if this enemy is a boss."""
|
|
return self.difficulty == EnemyDifficulty.BOSS
|
|
|
|
def has_tag(self, tag: str) -> bool:
|
|
"""Check if enemy has a specific tag."""
|
|
return tag.lower() in [t.lower() for t in self.tags]
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""
|
|
Serialize enemy template to dictionary.
|
|
|
|
Returns:
|
|
Dictionary containing all enemy data
|
|
"""
|
|
return {
|
|
"enemy_id": self.enemy_id,
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"base_stats": self.base_stats.to_dict(),
|
|
"abilities": self.abilities,
|
|
"loot_table": [entry.to_dict() for entry in self.loot_table],
|
|
"experience_reward": self.experience_reward,
|
|
"gold_reward_min": self.gold_reward_min,
|
|
"gold_reward_max": self.gold_reward_max,
|
|
"difficulty": self.difficulty.value,
|
|
"tags": self.tags,
|
|
"image_url": self.image_url,
|
|
"base_damage": self.base_damage,
|
|
"crit_chance": self.crit_chance,
|
|
"flee_chance": self.flee_chance,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> 'EnemyTemplate':
|
|
"""
|
|
Deserialize enemy template from dictionary.
|
|
|
|
Args:
|
|
data: Dictionary containing enemy data (from YAML or JSON)
|
|
|
|
Returns:
|
|
EnemyTemplate instance
|
|
"""
|
|
# Parse base stats
|
|
stats_data = data.get("base_stats", {})
|
|
base_stats = Stats.from_dict(stats_data)
|
|
|
|
# Parse loot table
|
|
loot_table = [
|
|
LootEntry.from_dict(entry)
|
|
for entry in data.get("loot_table", [])
|
|
]
|
|
|
|
# Parse difficulty
|
|
difficulty_value = data.get("difficulty", "easy")
|
|
if isinstance(difficulty_value, str):
|
|
difficulty = EnemyDifficulty(difficulty_value)
|
|
else:
|
|
difficulty = difficulty_value
|
|
|
|
return cls(
|
|
enemy_id=data["enemy_id"],
|
|
name=data["name"],
|
|
description=data.get("description", ""),
|
|
base_stats=base_stats,
|
|
abilities=data.get("abilities", []),
|
|
loot_table=loot_table,
|
|
experience_reward=data.get("experience_reward", 10),
|
|
gold_reward_min=data.get("gold_reward_min", 1),
|
|
gold_reward_max=data.get("gold_reward_max", 5),
|
|
difficulty=difficulty,
|
|
tags=data.get("tags", []),
|
|
image_url=data.get("image_url"),
|
|
base_damage=data.get("base_damage", 5),
|
|
crit_chance=data.get("crit_chance", 0.05),
|
|
flee_chance=data.get("flee_chance", 0.5),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation of the enemy template."""
|
|
return (
|
|
f"EnemyTemplate({self.enemy_id}, {self.name}, "
|
|
f"difficulty={self.difficulty.value}, "
|
|
f"xp={self.experience_reward})"
|
|
)
|