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:
217
api/app/models/enemy.py
Normal file
217
api/app/models/enemy.py
Normal 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})"
|
||||
)
|
||||
@@ -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)"
|
||||
|
||||
@@ -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%})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user