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

@@ -21,6 +21,7 @@ from app.services.database_service import get_database_service
from app.services.appwrite_service import AppwriteService
from app.services.class_loader import get_class_loader
from app.services.origin_service import get_origin_service
from app.services.static_item_loader import get_static_item_loader
from app.utils.logging import get_logger
logger = get_logger(__file__)
@@ -173,6 +174,23 @@ class CharacterService:
current_location=starting_location_id # Set starting location
)
# Add starting equipment to inventory
if player_class.starting_equipment:
item_loader = get_static_item_loader()
for item_id in player_class.starting_equipment:
item = item_loader.get_item(item_id)
if item:
character.add_item(item)
logger.debug("Added starting equipment",
character_id=character_id,
item_id=item_id,
item_name=item.name)
else:
logger.warning("Starting equipment item not found",
character_id=character_id,
item_id=item_id,
class_id=class_id)
# Serialize character to JSON
character_dict = character.to_dict()
character_json = json.dumps(character_dict)

View File

@@ -20,7 +20,7 @@ from app.models.stats import Stats
from app.models.abilities import Ability, AbilityLoader
from app.models.effects import Effect
from app.models.items import Item
from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType
from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType, StatType
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
@@ -94,6 +94,7 @@ class ActionResult:
combat_status: Final combat status if ended
next_combatant_id: ID of combatant whose turn is next
turn_effects: Effects that triggered at turn start/end
rewards: Combat rewards if victory (XP, gold, items)
"""
success: bool
@@ -105,6 +106,7 @@ class ActionResult:
next_combatant_id: Optional[str] = None
next_is_player: bool = True # True if next turn is player's
turn_effects: List[Dict[str, Any]] = field(default_factory=list)
rewards: Optional[Dict[str, Any]] = None # Populated on victory
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API response."""
@@ -127,6 +129,7 @@ class ActionResult:
"next_combatant_id": self.next_combatant_id,
"next_is_player": self.next_is_player,
"turn_effects": self.turn_effects,
"rewards": self.rewards,
}
@@ -451,6 +454,113 @@ class CombatService:
return rewards
def check_existing_combat(
self,
session_id: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""
Check if a session has an existing active combat encounter.
Returns combat summary if exists, None otherwise.
Args:
session_id: Game session ID
user_id: User ID for authorization
Returns:
Dictionary with combat summary if in combat, None otherwise
"""
logger.info("Checking for existing combat",
session_id=session_id)
session = self.session_service.get_session(session_id, user_id)
if not session.is_in_combat():
return None
# Get encounter details
encounter = self.get_combat_state(session_id, user_id)
if not encounter:
return None
# Build summary of combatants
players = []
enemies = []
for combatant in encounter.combatants:
combatant_info = {
"name": combatant.name,
"current_hp": combatant.current_hp,
"max_hp": combatant.max_hp,
"is_alive": combatant.is_alive(),
}
if combatant.is_player:
players.append(combatant_info)
else:
enemies.append(combatant_info)
return {
"has_active_combat": True,
"encounter_id": encounter.encounter_id,
"round_number": encounter.round_number,
"status": encounter.status.value,
"players": players,
"enemies": enemies,
}
def abandon_combat(
self,
session_id: str,
user_id: str
) -> bool:
"""
Abandon an existing combat encounter without completing it.
Deletes the encounter from the database and clears the session
reference. No rewards are distributed.
Args:
session_id: Game session ID
user_id: User ID for authorization
Returns:
True if combat was abandoned, False if no combat existed
"""
logger.info("Abandoning combat",
session_id=session_id)
session = self.session_service.get_session(session_id, user_id)
if not session.is_in_combat():
logger.info("No combat to abandon",
session_id=session_id)
return False
encounter_id = session.active_combat_encounter_id
# Delete encounter from repository
if encounter_id:
try:
self.combat_repository.delete_encounter(encounter_id)
logger.info("Deleted encounter from repository",
encounter_id=encounter_id)
except Exception as e:
logger.warning("Failed to delete encounter from repository",
encounter_id=encounter_id,
error=str(e))
# Clear session combat references
session.active_combat_encounter_id = None
session.combat_encounter = None # Clear legacy field too
session.update_activity()
self.session_service.update_session(session)
logger.info("Combat abandoned",
session_id=session_id,
encounter_id=encounter_id)
return True
# =========================================================================
# Action Execution
# =========================================================================
@@ -549,6 +659,7 @@ class CombatService:
if status == CombatStatus.VICTORY:
rewards = self._calculate_rewards(encounter, session, user_id)
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold."
result.rewards = rewards.to_dict()
# End encounter in repository
if session.active_combat_encounter_id:
@@ -699,6 +810,11 @@ class CombatService:
result.combat_ended = True
result.combat_status = status
# Calculate and distribute rewards on victory
if status == CombatStatus.VICTORY:
rewards = self._calculate_rewards(encounter, session, user_id)
result.rewards = rewards.to_dict()
# End encounter in repository
if session.active_combat_encounter_id:
self.combat_repository.end_encounter(
@@ -946,7 +1062,7 @@ class CombatService:
effect_type=EffectType.BUFF,
duration=1,
power=5, # +5 defense
stat_affected="constitution",
stat_affected=StatType.CONSTITUTION,
source="defend_action",
)
combatant.add_effect(defense_buff)

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

View File

@@ -177,6 +177,46 @@ class EnemyLoader:
if enemy.has_tag(tag)
]
def get_enemies_by_location(
self,
location_type: str,
difficulty: Optional[EnemyDifficulty] = None
) -> List[EnemyTemplate]:
"""
Get all enemies that can appear at a specific location type.
This is used by the encounter generator to find location-appropriate
enemies for random encounters.
Args:
location_type: Location type to filter by (e.g., "forest", "dungeon",
"town", "wilderness", "crypt", "ruins", "road")
difficulty: Optional difficulty filter to narrow results
Returns:
List of EnemyTemplate instances that can appear at the location
"""
if not self._loaded:
self.load_all_enemies()
candidates = [
enemy for enemy in self._enemy_cache.values()
if enemy.has_location_tag(location_type)
]
# Apply difficulty filter if specified
if difficulty is not None:
candidates = [e for e in candidates if e.difficulty == difficulty]
logger.debug(
"Enemies found for location",
location_type=location_type,
difficulty=difficulty.value if difficulty else None,
count=len(candidates)
)
return candidates
def get_random_enemies(
self,
count: int = 1,

View File

@@ -15,7 +15,7 @@ import yaml
from app.models.items import Item
from app.models.effects import Effect
from app.models.enums import ItemType, ItemRarity, EffectType
from app.models.enums import ItemType, ItemRarity, EffectType, DamageType
from app.utils.logging import get_logger
logger = get_logger(__file__)
@@ -178,6 +178,20 @@ class StaticItemLoader:
# Parse stat bonuses if present
stat_bonuses = template.get("stat_bonuses", {})
# Parse damage type if present (for weapons)
damage_type = None
damage_type_str = template.get("damage_type")
if damage_type_str:
try:
damage_type = DamageType(damage_type_str)
except ValueError:
logger.warning(
"Unknown damage type, defaulting to physical",
damage_type=damage_type_str,
item_id=item_id
)
damage_type = DamageType.PHYSICAL
return Item(
item_id=instance_id,
name=template.get("name", item_id),
@@ -188,6 +202,17 @@ class StaticItemLoader:
is_tradeable=template.get("is_tradeable", True),
stat_bonuses=stat_bonuses,
effects_on_use=effects,
# Weapon-specific fields
damage=template.get("damage", 0),
spell_power=template.get("spell_power", 0),
damage_type=damage_type,
crit_chance=template.get("crit_chance", 0.05),
crit_multiplier=template.get("crit_multiplier", 2.0),
# Armor-specific fields
defense=template.get("defense", 0),
resistance=template.get("resistance", 0),
# Level requirements
required_level=template.get("required_level", 1),
)
def _parse_effect(self, effect_data: Dict) -> Optional[Effect]: