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

View File

@@ -0,0 +1,594 @@
"""
Damage Calculator Service
A comprehensive, formula-driven damage calculation system for Code of Conquest.
Handles physical, magical, and elemental damage with LUK stat integration
for variance, critical hits, and accuracy.
Formulas:
Physical: (Weapon_Base + STR * 0.75) * Variance * Crit_Mult - DEF
Magical: (Ability_Base + INT * 0.75) * Variance * Crit_Mult - RES
Elemental: Split between physical and magical components
LUK Integration:
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
- Crit bonus: Base 5% + (LUK * 0.5%), max 25%
- Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll
"""
import random
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, List
from app.models.stats import Stats
from app.models.enums import DamageType
class CombatConstants:
"""
Combat system tuning constants.
These values control the balance of combat mechanics and can be
adjusted for game balance without modifying formula logic.
"""
# Stat Scaling
# How much primary stats (STR/INT) contribute to damage
# 0.75 means STR 14 adds +10.5 damage
STAT_SCALING_FACTOR: float = 0.75
# Hit/Miss System
BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate
LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point
DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10
MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss
# Critical Hits
DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit
LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point
MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills)
DEFAULT_CRIT_MULTIPLIER: float = 2.0
# Damage Variance
BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll
BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll
LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum
LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus)
BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance
LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point
# Defense Mitigation
# Ensures high-DEF targets still take meaningful damage
MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through
MIN_DAMAGE: int = 1 # Absolute minimum damage
@dataclass
class DamageResult:
"""
Result of a damage calculation.
Contains the calculated damage values, whether the attack was a crit or miss,
and a human-readable message for the combat log.
Attributes:
total_damage: Final damage after all calculations
physical_damage: Physical component (for split damage)
elemental_damage: Elemental component (for split damage)
damage_type: Primary damage type (physical, fire, etc.)
is_critical: Whether the attack was a critical hit
is_miss: Whether the attack missed entirely
variance_roll: The variance multiplier that was applied
raw_damage: Damage before defense mitigation
message: Human-readable description for combat log
"""
total_damage: int = 0
physical_damage: int = 0
elemental_damage: int = 0
damage_type: DamageType = DamageType.PHYSICAL
elemental_type: Optional[DamageType] = None
is_critical: bool = False
is_miss: bool = False
variance_roll: float = 1.0
raw_damage: int = 0
message: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Serialize damage result to dictionary."""
return {
"total_damage": self.total_damage,
"physical_damage": self.physical_damage,
"elemental_damage": self.elemental_damage,
"damage_type": self.damage_type.value if self.damage_type else "physical",
"elemental_type": self.elemental_type.value if self.elemental_type else None,
"is_critical": self.is_critical,
"is_miss": self.is_miss,
"variance_roll": round(self.variance_roll, 3),
"raw_damage": self.raw_damage,
"message": self.message,
}
class DamageCalculator:
"""
Formula-driven damage calculator for combat.
This class provides static methods for calculating all types of damage
in the combat system, including hit/miss chances, critical hits,
damage variance, and defense mitigation.
All formulas integrate the LUK stat for meaningful randomness while
maintaining a hard cap on miss chance to prevent frustration.
"""
@staticmethod
def calculate_hit_chance(
attacker_luck: int,
defender_dexterity: int,
skill_bonus: float = 0.0
) -> float:
"""
Calculate hit probability for an attack.
Formula:
miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025))
hit_chance = 1.0 - miss_chance
Args:
attacker_luck: Attacker's LUK stat
defender_dexterity: Defender's DEX stat
skill_bonus: Additional hit chance from skills (0.0 to 1.0)
Returns:
Hit probability as a float between 0.0 and 1.0
Examples:
LUK 8, DEX 10: miss = 10% - 4% + 0% = 6%
LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5%
LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
"""
# Base miss rate
base_miss = CombatConstants.BASE_MISS_CHANCE
# LUK reduces miss chance
luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION
# High DEX increases evasion (only DEX above 10 counts)
dex_above_base = max(0, defender_dexterity - 10)
dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS
# Calculate final miss chance with hard cap
miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus
miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance)
return 1.0 - miss_chance
@staticmethod
def calculate_crit_chance(
attacker_luck: int,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
skill_bonus: float = 0.0
) -> float:
"""
Calculate critical hit probability.
Formula:
crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus)
Args:
attacker_luck: Attacker's LUK stat
weapon_crit_chance: Base crit chance from weapon (default 5%)
skill_bonus: Additional crit chance from skills
Returns:
Crit probability as a float (capped at 25%)
Examples:
LUK 8, weapon 5%: crit = 5% + 4% = 9%
LUK 12, weapon 5%: crit = 5% + 6% = 11%
LUK 12, weapon 10%: crit = 10% + 6% = 16%
"""
# LUK bonus to crit
luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS
# Total crit chance with cap
total_crit = weapon_crit_chance + luk_bonus + skill_bonus
return min(CombatConstants.MAX_CRIT_CHANCE, total_crit)
@staticmethod
def calculate_variance(attacker_luck: int) -> float:
"""
Calculate damage variance multiplier with LUK bonus.
Hybrid variance system:
- Base roll: 95% to 105% of damage
- LUK grants chance for "lucky roll": 100% to 110% instead
Args:
attacker_luck: Attacker's LUK stat
Returns:
Variance multiplier (typically 0.95 to 1.10)
Examples:
LUK 8: 7% chance for lucky roll (100-110%)
LUK 12: 8% chance for lucky roll
"""
# Calculate lucky roll chance
lucky_chance = (
CombatConstants.BASE_LUCKY_CHANCE +
(attacker_luck * CombatConstants.LUK_LUCKY_BONUS)
)
# Roll for lucky variance
if random.random() < lucky_chance:
# Lucky roll: higher damage range
return random.uniform(
CombatConstants.LUCKY_VARIANCE_MIN,
CombatConstants.LUCKY_VARIANCE_MAX
)
else:
# Normal roll
return random.uniform(
CombatConstants.BASE_VARIANCE_MIN,
CombatConstants.BASE_VARIANCE_MAX
)
@staticmethod
def apply_defense(
raw_damage: int,
defense: int,
min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO
) -> int:
"""
Apply defense mitigation with minimum damage guarantee.
Ensures at least 20% of raw damage always goes through,
preventing high-DEF tanks from becoming unkillable.
Absolute minimum is always 1 damage.
Args:
raw_damage: Damage before defense
defense: Target's defense value
min_damage_ratio: Minimum % of raw damage that goes through
Returns:
Final damage after mitigation (minimum 1)
Examples:
raw=20, def=5: 20 - 5 = 15 damage
raw=20, def=18: max(4, 2) = 4 damage (20% minimum)
raw=10, def=100: max(2, -90) = 2 damage (20% minimum)
"""
# Calculate mitigated damage
mitigated = raw_damage - defense
# Minimum damage is 20% of raw, or 1, whichever is higher
min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio))
return max(min_damage, mitigated)
@classmethod
def calculate_physical_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int = 0,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
ability_base_power: int = 0,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate physical damage for a melee/ranged attack.
Formula:
Base = Weapon_Base + Ability_Power + (STR * 0.75)
Damage = Base * Variance * Crit_Mult - DEF
Args:
attacker_stats: Attacker's Stats (STR, LUK used)
defender_stats: Defender's Stats (DEX, CON used)
weapon_damage: Base damage from equipped weapon
weapon_crit_chance: Crit chance from weapon (default 5%)
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
ability_base_power: Additional base power from ability
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with calculated damage and metadata
"""
result = DamageResult(damage_type=DamageType.PHYSICAL)
# Step 1: Check for miss
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Attack missed!"
return result
# Step 2: Calculate base damage
# Formula: weapon + ability + (STR * scaling_factor)
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR
base_damage = weapon_damage + ability_base_power + str_bonus
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
damage = base_damage * variance
# Step 4: Check for critical hit
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
if random.random() < crit_chance:
result.is_critical = True
damage *= weapon_crit_multiplier
# Store raw damage before defense
result.raw_damage = int(damage)
# Step 5: Apply defense mitigation
final_damage = cls.apply_defense(int(damage), defender_stats.defense)
result.total_damage = final_damage
result.physical_damage = final_damage
# Build message
crit_text = " CRITICAL HIT!" if result.is_critical else ""
result.message = f"Dealt {final_damage} physical damage.{crit_text}"
return result
@classmethod
def calculate_magical_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
ability_base_power: int,
damage_type: DamageType = DamageType.FIRE,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate magical damage for a spell.
Spells CAN critically hit (same formula as physical).
LUK benefits all classes equally.
Formula:
Base = Ability_Power + (INT * 0.75)
Damage = Base * Variance * Crit_Mult - RES
Args:
attacker_stats: Attacker's Stats (INT, LUK used)
defender_stats: Defender's Stats (DEX, WIS used)
ability_base_power: Base power of the spell
damage_type: Type of magical damage (fire, ice, etc.)
weapon_crit_chance: Crit chance (from focus/staff)
weapon_crit_multiplier: Crit damage multiplier
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with calculated damage and metadata
"""
result = DamageResult(damage_type=damage_type)
# Step 1: Check for miss (spells can miss too)
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Spell missed!"
return result
# Step 2: Calculate base damage
# Formula: ability + (INT * scaling_factor)
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR
base_damage = ability_base_power + int_bonus
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
damage = base_damage * variance
# Step 4: Check for critical hit (spells CAN crit)
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
if random.random() < crit_chance:
result.is_critical = True
damage *= weapon_crit_multiplier
# Store raw damage before resistance
result.raw_damage = int(damage)
# Step 5: Apply resistance mitigation
final_damage = cls.apply_defense(int(damage), defender_stats.resistance)
result.total_damage = final_damage
result.elemental_damage = final_damage
# Build message
crit_text = " CRITICAL HIT!" if result.is_critical else ""
result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}"
return result
@classmethod
def calculate_elemental_weapon_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int,
weapon_crit_chance: float,
weapon_crit_multiplier: float,
physical_ratio: float,
elemental_ratio: float,
elemental_type: DamageType,
ability_base_power: int = 0,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate split damage for elemental weapons (e.g., Fire Sword).
Elemental weapons deal both physical AND elemental damage,
calculated separately against DEF and RES respectively.
Formula:
Physical = (Weapon * PHYS_RATIO + STR * 0.75 * PHYS_RATIO) - DEF
Elemental = (Weapon * ELEM_RATIO + INT * 0.75 * ELEM_RATIO) - RES
Total = Physical + Elemental
Recommended Split Ratios:
- Pure Physical: 100% / 0%
- Fire Sword: 70% / 30%
- Frost Blade: 60% / 40%
- Lightning Spear: 50% / 50%
Args:
attacker_stats: Attacker's Stats
defender_stats: Defender's Stats
weapon_damage: Base weapon damage
weapon_crit_chance: Crit chance from weapon
weapon_crit_multiplier: Crit damage multiplier
physical_ratio: Portion of damage that is physical (0.0-1.0)
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
elemental_type: Type of elemental damage
ability_base_power: Additional base power from ability
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with split physical/elemental damage
"""
result = DamageResult(
damage_type=DamageType.PHYSICAL,
elemental_type=elemental_type
)
# Step 1: Check for miss (single roll for entire attack)
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Attack missed!"
return result
# Step 2: Check for critical (single roll applies to both components)
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
is_crit = random.random() < crit_chance
result.is_critical = is_crit
crit_mult = weapon_crit_multiplier if is_crit else 1.0
# Step 3: Calculate physical component
# Physical uses STR scaling
phys_base = (weapon_damage + ability_base_power) * physical_ratio
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR * physical_ratio
phys_damage = (phys_base + str_bonus) * variance * crit_mult
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
# Step 4: Calculate elemental component
# Elemental uses INT scaling
elem_base = (weapon_damage + ability_base_power) * elemental_ratio
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR * elemental_ratio
elem_damage = (elem_base + int_bonus) * variance * crit_mult
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
# Step 5: Combine results
result.physical_damage = phys_final
result.elemental_damage = elem_final
result.total_damage = phys_final + elem_final
result.raw_damage = int(phys_damage + elem_damage)
# Build message
crit_text = " CRITICAL HIT!" if is_crit else ""
result.message = (
f"Dealt {result.total_damage} damage "
f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}"
)
return result
@classmethod
def calculate_aoe_damage(
cls,
attacker_stats: Stats,
defender_stats_list: List[Stats],
ability_base_power: int,
damage_type: DamageType = DamageType.FIRE,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> List[DamageResult]:
"""
Calculate AoE spell damage against multiple targets.
AoE spells deal FULL damage to all targets (balanced by higher mana costs).
Each target has independent hit/crit rolls but shares the base calculation.
Args:
attacker_stats: Attacker's Stats
defender_stats_list: List of defender Stats (one per target)
ability_base_power: Base power of the AoE spell
damage_type: Type of magical damage
weapon_crit_chance: Crit chance from focus/staff
weapon_crit_multiplier: Crit damage multiplier
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
List of DamageResult, one per target
"""
results = []
# Each target gets independent damage calculation
for defender_stats in defender_stats_list:
result = cls.calculate_magical_damage(
attacker_stats=attacker_stats,
defender_stats=defender_stats,
ability_base_power=ability_base_power,
damage_type=damage_type,
weapon_crit_chance=weapon_crit_chance,
weapon_crit_multiplier=weapon_crit_multiplier,
skill_hit_bonus=skill_hit_bonus,
skill_crit_bonus=skill_crit_bonus,
)
results.append(result)
return results