Combat Backend & Data Models

- Implement Combat Service
- Implement Damage Calculator
- Implement Effect Processor
- Implement Combat Actions
- Created Combat API Endpoints
This commit is contained in:
2025-11-26 15:43:20 -06:00
parent 30c3b800e6
commit 03ab783eeb
22 changed files with 9091 additions and 5 deletions

217
api/app/models/enemy.py Normal file
View File

@@ -0,0 +1,217 @@
"""
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"
@dataclass
class LootEntry:
"""
Single entry in an enemy's loot table.
Attributes:
item_id: Reference to item definition
drop_chance: Probability of dropping (0.0 to 1.0)
quantity_min: Minimum quantity if dropped
quantity_max: Maximum quantity if dropped
"""
item_id: str
drop_chance: float = 0.1
quantity_min: int = 1
quantity_max: int = 1
def to_dict(self) -> Dict[str, Any]:
"""Serialize loot entry to dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry':
"""Deserialize loot entry from dictionary."""
return cls(
item_id=data["item_id"],
drop_chance=data.get("drop_chance", 0.1),
quantity_min=data.get("quantity_min", 1),
quantity_max=data.get("quantity_max", 1),
)
@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})"
)

View File

@@ -65,6 +65,12 @@ class Item:
crit_chance: float = 0.05 # 5% default critical hit chance
crit_multiplier: float = 2.0 # 2x damage on critical hit
# Elemental weapon properties (for split damage like Fire Sword)
# These enable weapons to deal both physical AND elemental damage
elemental_damage_type: Optional[DamageType] = None # Secondary damage type (fire, ice, etc.)
physical_ratio: float = 1.0 # Portion of damage that is physical (0.0-1.0)
elemental_ratio: float = 0.0 # Portion of damage that is elemental (0.0-1.0)
# Armor-specific
defense: int = 0
resistance: int = 0
@@ -89,6 +95,27 @@ class Item:
"""Check if this item is a quest item."""
return self.item_type == ItemType.QUEST_ITEM
def is_elemental_weapon(self) -> bool:
"""
Check if this weapon deals elemental damage (split damage).
Elemental weapons deal both physical AND elemental damage,
calculated separately against DEF and RES.
Examples:
Fire Sword: 70% physical / 30% fire
Frost Blade: 60% physical / 40% ice
Lightning Spear: 50% physical / 50% lightning
Returns:
True if weapon has elemental damage component
"""
return (
self.is_weapon() and
self.elemental_ratio > 0.0 and
self.elemental_damage_type is not None
)
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
"""
Check if a character can equip this item.
@@ -133,6 +160,8 @@ class Item:
data["item_type"] = self.item_type.value
if self.damage_type:
data["damage_type"] = self.damage_type.value
if self.elemental_damage_type:
data["elemental_damage_type"] = self.elemental_damage_type.value
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
return data
@@ -150,6 +179,11 @@ class Item:
# Convert string values back to enums
item_type = ItemType(data["item_type"])
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
elemental_damage_type = (
DamageType(data["elemental_damage_type"])
if data.get("elemental_damage_type")
else None
)
# Deserialize effects
effects = []
@@ -169,6 +203,9 @@ class Item:
damage_type=damage_type,
crit_chance=data.get("crit_chance", 0.05),
crit_multiplier=data.get("crit_multiplier", 2.0),
elemental_damage_type=elemental_damage_type,
physical_ratio=data.get("physical_ratio", 1.0),
elemental_ratio=data.get("elemental_ratio", 0.0),
defense=data.get("defense", 0),
resistance=data.get("resistance", 0),
required_level=data.get("required_level", 1),
@@ -178,6 +215,12 @@ class Item:
def __repr__(self) -> str:
"""String representation of the item."""
if self.is_weapon():
if self.is_elemental_weapon():
return (
f"Item({self.name}, elemental_weapon, dmg={self.damage}, "
f"phys={self.physical_ratio:.0%}/{self.elemental_damage_type.value}={self.elemental_ratio:.0%}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
)
return (
f"Item({self.name}, weapon, dmg={self.damage}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"

View File

@@ -86,6 +86,63 @@ class Stats:
"""
return self.wisdom // 2
@property
def crit_bonus(self) -> float:
"""
Calculate critical hit chance bonus from luck.
Formula: luck * 0.5% (0.005)
This bonus is added to the weapon's base crit chance.
The total crit chance is capped at 25% in the DamageCalculator.
Returns:
Crit chance bonus as a decimal (e.g., 0.04 for LUK 8)
Examples:
LUK 8: 0.04 (4% bonus)
LUK 12: 0.06 (6% bonus)
"""
return self.luck * 0.005
@property
def hit_bonus(self) -> float:
"""
Calculate hit chance bonus (miss reduction) from luck.
Formula: luck * 0.5% (0.005)
This reduces the base 10% miss chance. The minimum miss
chance is hard capped at 5% to prevent frustration.
Returns:
Miss reduction as a decimal (e.g., 0.04 for LUK 8)
Examples:
LUK 8: 0.04 (reduces miss from 10% to 6%)
LUK 12: 0.06 (reduces miss from 10% to 4%, capped at 5%)
"""
return self.luck * 0.005
@property
def lucky_roll_chance(self) -> float:
"""
Calculate chance for a "lucky" high damage variance roll.
Formula: 5% + (luck * 0.25%)
When triggered, damage variance uses 100%-110% instead of 95%-105%.
This gives LUK characters more frequent high damage rolls.
Returns:
Lucky roll chance as a decimal
Examples:
LUK 8: 0.07 (7% chance for lucky roll)
LUK 12: 0.08 (8% chance for lucky roll)
"""
return 0.05 + (self.luck * 0.0025)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize stats to a dictionary.
@@ -140,5 +197,6 @@ class Stats:
f"CON={self.constitution}, INT={self.intelligence}, "
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
f"HP={self.hit_points}, MP={self.mana_points}, "
f"DEF={self.defense}, RES={self.resistance})"
f"DEF={self.defense}, RES={self.resistance}, "
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
)