Combat foundation complete

This commit is contained in:
2025-11-27 22:18:58 -06:00
parent dd92cf5991
commit 6d3fb63355
33 changed files with 1870 additions and 85 deletions

View File

@@ -0,0 +1,308 @@
"""
Encounter Generator Service - Generate random combat encounters.
This service generates location-appropriate, level-scaled encounter groups
for the "Search for Monsters" feature. Players can select from generated
encounter options to initiate combat.
"""
import random
import uuid
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from collections import Counter
from app.models.enemy import EnemyTemplate, EnemyDifficulty
from app.services.enemy_loader import get_enemy_loader
from app.utils.logging import get_logger
logger = get_logger(__file__)
@dataclass
class EncounterGroup:
"""
A generated encounter option for the player to choose.
Attributes:
group_id: Unique identifier for this encounter option
enemies: List of enemy_ids that will spawn
enemy_names: Display names for the UI
display_name: Formatted display string (e.g., "3 Goblin Scouts")
challenge: Difficulty label ("Easy", "Medium", "Hard", "Boss")
total_xp: Total XP reward (not displayed to player, used internally)
"""
group_id: str
enemies: List[str] # List of enemy_ids
enemy_names: List[str] # Display names
display_name: str # Formatted for display
challenge: str # "Easy", "Medium", "Hard", "Boss"
total_xp: int # Internal tracking, not displayed
def to_dict(self) -> Dict[str, Any]:
"""Serialize encounter group for API response."""
return {
"group_id": self.group_id,
"enemies": self.enemies,
"enemy_names": self.enemy_names,
"display_name": self.display_name,
"challenge": self.challenge,
}
class EncounterGenerator:
"""
Generates random encounter groups for a given location and character level.
Encounter difficulty is determined by:
- Character level (higher level = more enemies, harder varieties)
- Location type (different monsters in different areas)
- Random variance (some encounters harder/easier than average)
"""
def __init__(self):
"""Initialize the encounter generator."""
self.enemy_loader = get_enemy_loader()
def generate_encounters(
self,
location_type: str,
character_level: int,
num_encounters: int = 4
) -> List[EncounterGroup]:
"""
Generate multiple encounter options for the player to choose from.
Args:
location_type: Type of location (e.g., "forest", "town", "dungeon")
character_level: Current character level (1-20+)
num_encounters: Number of encounter options to generate (default 4)
Returns:
List of EncounterGroup options, each with different difficulty
"""
# Get enemies available at this location
available_enemies = self.enemy_loader.get_enemies_by_location(location_type)
if not available_enemies:
logger.warning(
"No enemies found for location",
location_type=location_type
)
return []
# Generate a mix of difficulties
# Always try to include: 1 Easy, 1-2 Medium, 0-1 Hard
encounters = []
difficulty_mix = self._get_difficulty_mix(character_level, num_encounters)
for target_difficulty in difficulty_mix:
encounter = self._generate_single_encounter(
available_enemies=available_enemies,
character_level=character_level,
target_difficulty=target_difficulty
)
if encounter:
encounters.append(encounter)
logger.info(
"Generated encounters",
location_type=location_type,
character_level=character_level,
num_encounters=len(encounters)
)
return encounters
def _get_difficulty_mix(
self,
character_level: int,
num_encounters: int
) -> List[str]:
"""
Determine the mix of encounter difficulties to generate.
Lower-level characters see more easy encounters.
Higher-level characters see more hard encounters.
Args:
character_level: Character's current level
num_encounters: Total encounters to generate
Returns:
List of target difficulty strings
"""
if character_level <= 2:
# Very low level: mostly easy
mix = ["Easy", "Easy", "Medium", "Easy"]
elif character_level <= 5:
# Low level: easy and medium
mix = ["Easy", "Medium", "Medium", "Hard"]
elif character_level <= 10:
# Mid level: balanced
mix = ["Easy", "Medium", "Hard", "Hard"]
else:
# High level: harder encounters
mix = ["Medium", "Hard", "Hard", "Boss"]
return mix[:num_encounters]
def _generate_single_encounter(
self,
available_enemies: List[EnemyTemplate],
character_level: int,
target_difficulty: str
) -> Optional[EncounterGroup]:
"""
Generate a single encounter group.
Args:
available_enemies: Pool of enemies to choose from
character_level: Character's level for scaling
target_difficulty: Target difficulty ("Easy", "Medium", "Hard", "Boss")
Returns:
EncounterGroup or None if generation fails
"""
# Map target difficulty to enemy difficulty levels
difficulty_mapping = {
"Easy": [EnemyDifficulty.EASY],
"Medium": [EnemyDifficulty.EASY, EnemyDifficulty.MEDIUM],
"Hard": [EnemyDifficulty.MEDIUM, EnemyDifficulty.HARD],
"Boss": [EnemyDifficulty.HARD, EnemyDifficulty.BOSS],
}
allowed_difficulties = difficulty_mapping.get(target_difficulty, [EnemyDifficulty.EASY])
# Filter enemies by difficulty
candidates = [
e for e in available_enemies
if e.difficulty in allowed_difficulties
]
if not candidates:
# Fall back to any available enemy
candidates = available_enemies
if not candidates:
return None
# Determine enemy count based on difficulty and level
enemy_count = self._calculate_enemy_count(
target_difficulty=target_difficulty,
character_level=character_level
)
# Select enemies (allowing duplicates for packs)
selected_enemies = random.choices(candidates, k=enemy_count)
# Build encounter group
enemy_ids = [e.enemy_id for e in selected_enemies]
enemy_names = [e.name for e in selected_enemies]
total_xp = sum(e.experience_reward for e in selected_enemies)
# Create display name (e.g., "3 Goblin Scouts" or "2 Goblins, 1 Goblin Shaman")
display_name = self._format_display_name(enemy_names)
return EncounterGroup(
group_id=f"enc_{uuid.uuid4().hex[:8]}",
enemies=enemy_ids,
enemy_names=enemy_names,
display_name=display_name,
challenge=target_difficulty,
total_xp=total_xp
)
def _calculate_enemy_count(
self,
target_difficulty: str,
character_level: int
) -> int:
"""
Calculate how many enemies should be in the encounter.
Args:
target_difficulty: Target difficulty level
character_level: Character's level
Returns:
Number of enemies to include
"""
# Base counts by difficulty
base_counts = {
"Easy": (1, 2), # 1-2 enemies
"Medium": (2, 3), # 2-3 enemies
"Hard": (2, 4), # 2-4 enemies
"Boss": (1, 3), # 1 boss + 0-2 adds
}
min_count, max_count = base_counts.get(target_difficulty, (1, 2))
# Scale slightly with level (higher level = can handle more)
level_bonus = min(character_level // 5, 2) # +1 enemy every 5 levels, max +2
max_count = min(max_count + level_bonus, 6) # Cap at 6 enemies
return random.randint(min_count, max_count)
def _format_display_name(self, enemy_names: List[str]) -> str:
"""
Format enemy names for display.
Examples:
["Goblin Scout"] -> "Goblin Scout"
["Goblin Scout", "Goblin Scout", "Goblin Scout"] -> "3 Goblin Scouts"
["Goblin Scout", "Goblin Shaman"] -> "Goblin Scout, Goblin Shaman"
Args:
enemy_names: List of enemy display names
Returns:
Formatted display string
"""
if len(enemy_names) == 1:
return enemy_names[0]
# Count occurrences
counts = Counter(enemy_names)
if len(counts) == 1:
# All same enemy type
name = list(counts.keys())[0]
count = list(counts.values())[0]
# Simple pluralization
if count > 1:
if name.endswith('f'):
# wolf -> wolves
plural_name = name[:-1] + "ves"
elif name.endswith('s') or name.endswith('x') or name.endswith('ch'):
plural_name = name + "es"
else:
plural_name = name + "s"
return f"{count} {plural_name}"
return name
else:
# Mixed enemy types - list them
parts = []
for name, count in counts.items():
if count > 1:
parts.append(f"{count}x {name}")
else:
parts.append(name)
return ", ".join(parts)
# Global instance
_generator_instance: Optional[EncounterGenerator] = None
def get_encounter_generator() -> EncounterGenerator:
"""
Get the global EncounterGenerator instance.
Returns:
Singleton EncounterGenerator instance
"""
global _generator_instance
if _generator_instance is None:
_generator_instance = EncounterGenerator()
return _generator_instance