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

@@ -169,8 +169,12 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(chat_bp)
logger.info("Chat API blueprint registered")
# Import and register Combat API blueprint
from app.api.combat import combat_bp
app.register_blueprint(combat_bp)
logger.info("Combat API blueprint registered")
# TODO: Register additional blueprints as they are created
# from app.api import combat, marketplace, shop
# app.register_blueprint(combat.bp, url_prefix='/api/v1/combat')
# from app.api import marketplace, shop
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')

729
api/app/api/combat.py Normal file
View File

@@ -0,0 +1,729 @@
"""
Combat API Blueprint
This module provides API endpoints for turn-based combat:
- Starting combat encounters
- Executing combat actions (attack, ability, defend, flee)
- Getting combat state
- Processing enemy turns
"""
from flask import Blueprint, request
from app.services.combat_service import (
get_combat_service,
CombatAction,
CombatError,
NotInCombatError,
AlreadyInCombatError,
InvalidActionError,
InsufficientResourceError,
)
from app.models.enums import CombatStatus
from app.utils.response import (
success_response,
error_response,
not_found_response,
validation_error_response
)
from app.utils.auth import require_auth, get_current_user
from app.utils.logging import get_logger
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
combat_bp = Blueprint('combat', __name__, url_prefix='/api/v1/combat')
# =============================================================================
# Combat Lifecycle Endpoints
# =============================================================================
@combat_bp.route('/start', methods=['POST'])
@require_auth
def start_combat():
"""
Start a new combat encounter.
Creates a combat encounter with the session's character(s) vs specified enemies.
Rolls initiative and sets up turn order.
Request JSON:
{
"session_id": "sess_123",
"enemy_ids": ["goblin", "goblin", "goblin_shaman"]
}
Returns:
{
"encounter_id": "enc_abc123",
"combatants": [...],
"turn_order": [...],
"current_turn": "char_456",
"round_number": 1,
"status": "active"
}
Errors:
400: Missing required fields
400: Already in combat
404: Enemy template not found
"""
user = get_current_user()
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"field": "body", "issue": "Missing JSON body"}
)
# Validate required fields
session_id = data.get("session_id")
enemy_ids = data.get("enemy_ids", [])
if not session_id:
return validation_error_response(
message="session_id is required",
details={"field": "session_id", "issue": "Missing required field"}
)
if not enemy_ids:
return validation_error_response(
message="enemy_ids is required and must not be empty",
details={"field": "enemy_ids", "issue": "Missing or empty list"}
)
try:
combat_service = get_combat_service()
encounter = combat_service.start_combat(
session_id=session_id,
user_id=user["user_id"],
enemy_ids=enemy_ids,
)
# Format response
current = encounter.get_current_combatant()
response_data = {
"encounter_id": encounter.encounter_id,
"combatants": [
{
"combatant_id": c.combatant_id,
"name": c.name,
"is_player": c.is_player,
"current_hp": c.current_hp,
"max_hp": c.max_hp,
"current_mp": c.current_mp,
"max_mp": c.max_mp,
"initiative": c.initiative,
"abilities": c.abilities,
}
for c in encounter.combatants
],
"turn_order": encounter.turn_order,
"current_turn": current.combatant_id if current else None,
"round_number": encounter.round_number,
"status": encounter.status.value,
}
logger.info("Combat started via API",
session_id=session_id,
encounter_id=encounter.encounter_id,
enemy_count=len(enemy_ids))
return success_response(response_data)
except AlreadyInCombatError as e:
logger.warning("Attempt to start combat while already in combat",
session_id=session_id)
return error_response(
status_code=400,
message=str(e),
error_code="ALREADY_IN_COMBAT"
)
except ValueError as e:
logger.warning("Invalid enemy ID",
session_id=session_id,
error=str(e))
return not_found_response(message=str(e))
except Exception as e:
logger.error("Failed to start combat",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to start combat",
error_code="COMBAT_START_ERROR"
)
@combat_bp.route('/<session_id>/state', methods=['GET'])
@require_auth
def get_combat_state(session_id: str):
"""
Get current combat state for a session.
Returns the full combat encounter state including all combatants,
turn order, combat log, and current status.
Path Parameters:
session_id: Game session ID
Returns:
{
"in_combat": true,
"encounter": {
"encounter_id": "...",
"combatants": [...],
"turn_order": [...],
"current_turn": "...",
"round_number": 1,
"status": "active",
"combat_log": [...]
}
}
or if not in combat:
{
"in_combat": false,
"encounter": null
}
"""
user = get_current_user()
try:
combat_service = get_combat_service()
encounter = combat_service.get_combat_state(session_id, user["user_id"])
if not encounter:
return success_response({
"in_combat": False,
"encounter": None
})
current = encounter.get_current_combatant()
response_data = {
"in_combat": True,
"encounter": {
"encounter_id": encounter.encounter_id,
"combatants": [
{
"combatant_id": c.combatant_id,
"name": c.name,
"is_player": c.is_player,
"current_hp": c.current_hp,
"max_hp": c.max_hp,
"current_mp": c.current_mp,
"max_mp": c.max_mp,
"is_alive": c.is_alive(),
"is_stunned": c.is_stunned(),
"active_effects": [
{"name": e.name, "duration": e.duration}
for e in c.active_effects
],
"abilities": c.abilities,
"cooldowns": c.cooldowns,
}
for c in encounter.combatants
],
"turn_order": encounter.turn_order,
"current_turn": current.combatant_id if current else None,
"round_number": encounter.round_number,
"status": encounter.status.value,
"combat_log": encounter.combat_log[-10:], # Last 10 entries
}
}
return success_response(response_data)
except Exception as e:
logger.error("Failed to get combat state",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to get combat state",
error_code="COMBAT_STATE_ERROR"
)
# =============================================================================
# Action Execution Endpoints
# =============================================================================
@combat_bp.route('/<session_id>/action', methods=['POST'])
@require_auth
def execute_action(session_id: str):
"""
Execute a combat action for a combatant.
Processes the specified action (attack, ability, defend, flee, item)
for the given combatant. Must be that combatant's turn.
Path Parameters:
session_id: Game session ID
Request JSON:
{
"combatant_id": "char_456",
"action_type": "attack" | "ability" | "defend" | "flee" | "item",
"target_ids": ["enemy_1"], // Optional, auto-targets if omitted
"ability_id": "fireball", // Required for ability actions
"item_id": "health_potion" // Required for item actions
}
Returns:
{
"success": true,
"message": "Attack hits for 15 damage!",
"damage_results": [...],
"effects_applied": [...],
"combat_ended": false,
"combat_status": null,
"next_combatant_id": "goblin_0"
}
Errors:
400: Missing required fields
400: Not this combatant's turn
400: Invalid action
404: Session not in combat
"""
user = get_current_user()
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"field": "body", "issue": "Missing JSON body"}
)
# Validate required fields
combatant_id = data.get("combatant_id")
action_type = data.get("action_type")
if not combatant_id:
return validation_error_response(
message="combatant_id is required",
details={"field": "combatant_id", "issue": "Missing required field"}
)
if not action_type:
return validation_error_response(
message="action_type is required",
details={"field": "action_type", "issue": "Missing required field"}
)
valid_actions = ["attack", "ability", "defend", "flee", "item"]
if action_type not in valid_actions:
return validation_error_response(
message=f"Invalid action_type. Must be one of: {valid_actions}",
details={"field": "action_type", "issue": "Invalid value"}
)
# Validate ability_id for ability actions
if action_type == "ability" and not data.get("ability_id"):
return validation_error_response(
message="ability_id is required for ability actions",
details={"field": "ability_id", "issue": "Missing required field"}
)
try:
combat_service = get_combat_service()
action = CombatAction(
action_type=action_type,
target_ids=data.get("target_ids", []),
ability_id=data.get("ability_id"),
item_id=data.get("item_id"),
)
result = combat_service.execute_action(
session_id=session_id,
user_id=user["user_id"],
combatant_id=combatant_id,
action=action,
)
logger.info("Combat action executed",
session_id=session_id,
combatant_id=combatant_id,
action_type=action_type,
success=result.success)
return success_response(result.to_dict())
except NotInCombatError as e:
logger.warning("Action attempted while not in combat",
session_id=session_id)
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
logger.warning("Invalid combat action",
session_id=session_id,
combatant_id=combatant_id,
error=str(e))
return error_response(
status_code=400,
message=str(e),
error_code="INVALID_ACTION"
)
except InsufficientResourceError as e:
logger.warning("Insufficient resources for action",
session_id=session_id,
combatant_id=combatant_id,
error=str(e))
return error_response(
status_code=400,
message=str(e),
error_code="INSUFFICIENT_RESOURCES"
)
except Exception as e:
logger.error("Failed to execute combat action",
session_id=session_id,
combatant_id=combatant_id,
action_type=action_type,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to execute action",
error_code="ACTION_EXECUTION_ERROR"
)
@combat_bp.route('/<session_id>/enemy-turn', methods=['POST'])
@require_auth
def execute_enemy_turn(session_id: str):
"""
Execute the current enemy's turn using AI logic.
Called when it's an enemy combatant's turn. The enemy AI will
automatically choose and execute an appropriate action.
Path Parameters:
session_id: Game session ID
Returns:
{
"success": true,
"message": "Goblin attacks Hero for 8 damage!",
"damage_results": [...],
"effects_applied": [...],
"combat_ended": false,
"combat_status": null,
"next_combatant_id": "char_456"
}
Errors:
400: Current combatant is not an enemy
404: Session not in combat
"""
user = get_current_user()
try:
combat_service = get_combat_service()
result = combat_service.execute_enemy_turn(
session_id=session_id,
user_id=user["user_id"],
)
logger.info("Enemy turn executed",
session_id=session_id,
success=result.success)
return success_response(result.to_dict())
except NotInCombatError as e:
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
return error_response(
status_code=400,
message=str(e),
error_code="INVALID_ACTION"
)
except Exception as e:
logger.error("Failed to execute enemy turn",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to execute enemy turn",
error_code="ENEMY_TURN_ERROR"
)
@combat_bp.route('/<session_id>/flee', methods=['POST'])
@require_auth
def attempt_flee(session_id: str):
"""
Attempt to flee from combat.
The current combatant attempts to flee. Success is based on
DEX comparison with enemies. Failed flee attempts consume the turn.
Path Parameters:
session_id: Game session ID
Request JSON:
{
"combatant_id": "char_456"
}
Returns:
{
"success": true,
"message": "Successfully fled from combat!",
"combat_ended": true,
"combat_status": "fled"
}
or on failure:
{
"success": false,
"message": "Failed to flee! (Roll: 0.35, Needed: 0.50)",
"combat_ended": false
}
"""
user = get_current_user()
data = request.get_json() or {}
combatant_id = data.get("combatant_id")
try:
combat_service = get_combat_service()
action = CombatAction(
action_type="flee",
target_ids=[],
)
result = combat_service.execute_action(
session_id=session_id,
user_id=user["user_id"],
combatant_id=combatant_id,
action=action,
)
return success_response(result.to_dict())
except NotInCombatError:
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
return error_response(
status_code=400,
message=str(e),
error_code="INVALID_ACTION"
)
except Exception as e:
logger.error("Failed flee attempt",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to attempt flee",
error_code="FLEE_ERROR"
)
@combat_bp.route('/<session_id>/end', methods=['POST'])
@require_auth
def end_combat(session_id: str):
"""
Force end the current combat (debug/admin endpoint).
Ends combat with the specified outcome. Should normally only be used
for debugging or admin purposes - combat usually ends automatically.
Path Parameters:
session_id: Game session ID
Request JSON:
{
"outcome": "victory" | "defeat" | "fled"
}
Returns:
{
"outcome": "victory",
"rewards": {
"experience": 100,
"gold": 50,
"items": [...],
"level_ups": []
}
}
"""
user = get_current_user()
data = request.get_json() or {}
outcome_str = data.get("outcome", "fled")
# Parse outcome
try:
outcome = CombatStatus(outcome_str)
except ValueError:
return validation_error_response(
message="Invalid outcome. Must be: victory, defeat, or fled",
details={"field": "outcome", "issue": "Invalid value"}
)
try:
combat_service = get_combat_service()
rewards = combat_service.end_combat(
session_id=session_id,
user_id=user["user_id"],
outcome=outcome,
)
logger.info("Combat force-ended",
session_id=session_id,
outcome=outcome_str)
return success_response({
"outcome": outcome_str,
"rewards": rewards.to_dict() if outcome == CombatStatus.VICTORY else None,
})
except NotInCombatError:
return not_found_response(message="Session is not in combat")
except Exception as e:
logger.error("Failed to end combat",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to end combat",
error_code="COMBAT_END_ERROR"
)
# =============================================================================
# Utility Endpoints
# =============================================================================
@combat_bp.route('/enemies', methods=['GET'])
def list_enemies():
"""
List all available enemy templates.
Returns a list of all enemy templates that can be used in combat.
Useful for encounter building and testing.
Query Parameters:
difficulty: Filter by difficulty (easy, medium, hard, boss)
tag: Filter by tag (undead, beast, humanoid, etc.)
Returns:
{
"enemies": [
{
"enemy_id": "goblin",
"name": "Goblin Scout",
"difficulty": "easy",
"tags": ["humanoid", "goblinoid"],
"experience_reward": 15
},
...
]
}
"""
from app.services.enemy_loader import get_enemy_loader
from app.models.enemy import EnemyDifficulty
difficulty = request.args.get("difficulty")
tag = request.args.get("tag")
try:
enemy_loader = get_enemy_loader()
enemy_loader.load_all_enemies()
if difficulty:
try:
diff = EnemyDifficulty(difficulty)
enemies = enemy_loader.get_enemies_by_difficulty(diff)
except ValueError:
return validation_error_response(
message="Invalid difficulty",
details={"field": "difficulty", "issue": "Must be: easy, medium, hard, boss"}
)
elif tag:
enemies = enemy_loader.get_enemies_by_tag(tag)
else:
enemies = list(enemy_loader.get_all_cached().values())
response_data = {
"enemies": [
{
"enemy_id": e.enemy_id,
"name": e.name,
"description": e.description[:100] + "..." if len(e.description) > 100 else e.description,
"difficulty": e.difficulty.value,
"tags": e.tags,
"experience_reward": e.experience_reward,
"gold_reward_range": [e.gold_reward_min, e.gold_reward_max],
}
for e in enemies
]
}
return success_response(response_data)
except Exception as e:
logger.error("Failed to list enemies",
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to list enemies",
error_code="ENEMY_LIST_ERROR"
)
@combat_bp.route('/enemies/<enemy_id>', methods=['GET'])
def get_enemy_details(enemy_id: str):
"""
Get detailed information about a specific enemy template.
Path Parameters:
enemy_id: Enemy template ID
Returns:
{
"enemy_id": "goblin",
"name": "Goblin Scout",
"description": "...",
"base_stats": {...},
"abilities": [...],
"loot_table": [...],
"difficulty": "easy",
...
}
"""
from app.services.enemy_loader import get_enemy_loader
try:
enemy_loader = get_enemy_loader()
enemy = enemy_loader.load_enemy(enemy_id)
if not enemy:
return not_found_response(message=f"Enemy not found: {enemy_id}")
return success_response(enemy.to_dict())
except Exception as e:
logger.error("Failed to get enemy details",
enemy_id=enemy_id,
error=str(e),
exc_info=True)
return error_response(
status_code=500,
message="Failed to get enemy details",
error_code="ENEMY_DETAILS_ERROR"
)

View File

@@ -0,0 +1,55 @@
# Bandit - Medium humanoid with weapon
# A highway robber armed with sword and dagger
enemy_id: bandit
name: Bandit Rogue
description: >
A rough-looking human in worn leather armor, their face partially hidden
by a tattered hood. They fight with a chipped sword and keep a dagger
ready for backstabs. Desperation has made them dangerous.
base_stats:
strength: 12
dexterity: 14
constitution: 10
intelligence: 10
wisdom: 8
charisma: 8
luck: 10
abilities:
- basic_attack
- quick_strike
- dirty_trick
loot_table:
- item_id: bandit_sword
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- item_id: leather_armor
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: lockpick
drop_chance: 0.25
quantity_min: 1
quantity_max: 3
- item_id: gold_coin
drop_chance: 0.80
quantity_min: 5
quantity_max: 20
experience_reward: 35
gold_reward_min: 10
gold_reward_max: 30
difficulty: medium
tags:
- humanoid
- rogue
- armed
base_damage: 8
crit_chance: 0.12
flee_chance: 0.45

View File

@@ -0,0 +1,52 @@
# Dire Wolf - Medium beast enemy
# A large, ferocious predator
enemy_id: dire_wolf
name: Dire Wolf
description: >
A massive wolf the size of a horse, with matted black fur and eyes
that glow with predatory intelligence. Its fangs are as long as daggers,
and its growl rumbles like distant thunder.
base_stats:
strength: 14
dexterity: 14
constitution: 12
intelligence: 4
wisdom: 10
charisma: 6
luck: 8
abilities:
- basic_attack
- savage_bite
- pack_howl
loot_table:
- item_id: wolf_pelt
drop_chance: 0.60
quantity_min: 1
quantity_max: 1
- item_id: wolf_fang
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- item_id: beast_meat
drop_chance: 0.70
quantity_min: 1
quantity_max: 3
experience_reward: 40
gold_reward_min: 0
gold_reward_max: 5
difficulty: medium
tags:
- beast
- wolf
- large
- pack
base_damage: 10
crit_chance: 0.10
flee_chance: 0.40

View File

@@ -0,0 +1,45 @@
# Goblin - Easy melee enemy (STR-focused)
# A small, cunning creature that attacks in groups
enemy_id: goblin
name: Goblin Scout
description: >
A small, green-skinned creature with pointed ears and sharp teeth.
Goblins are cowardly alone but dangerous in groups, using crude weapons
and dirty tactics to overwhelm their prey.
base_stats:
strength: 8
dexterity: 12
constitution: 6
intelligence: 6
wisdom: 6
charisma: 4
luck: 8
abilities:
- basic_attack
loot_table:
- item_id: rusty_dagger
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
experience_reward: 15
gold_reward_min: 2
gold_reward_max: 8
difficulty: easy
tags:
- humanoid
- goblinoid
- small
base_damage: 4
crit_chance: 0.05
flee_chance: 0.60

View File

@@ -0,0 +1,52 @@
# Goblin Shaman - Easy caster enemy (INT-focused)
# A goblin spellcaster that provides magical support
enemy_id: goblin_shaman
name: Goblin Shaman
description: >
A hunched goblin wrapped in tattered robes, clutching a staff adorned
with bones and feathers. It mutters dark incantations and hurls bolts
of sickly green fire at its enemies.
base_stats:
strength: 4
dexterity: 10
constitution: 6
intelligence: 12
wisdom: 10
charisma: 6
luck: 10
abilities:
- basic_attack
- fire_bolt
- minor_heal
loot_table:
- item_id: shaman_staff
drop_chance: 0.10
quantity_min: 1
quantity_max: 1
- item_id: mana_potion_small
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.60
quantity_min: 3
quantity_max: 8
experience_reward: 25
gold_reward_min: 5
gold_reward_max: 15
difficulty: easy
tags:
- humanoid
- goblinoid
- caster
- small
base_damage: 3
crit_chance: 0.08
flee_chance: 0.55

View File

@@ -0,0 +1,58 @@
# Orc Berserker - Hard heavy hitter
# A fearsome orc warrior in a battle rage
enemy_id: orc_berserker
name: Orc Berserker
description: >
A towering mass of green muscle and fury, covered in tribal war paint
and scars from countless battles. Foam flecks at the corners of its
mouth as it swings a massive greataxe with terrifying speed. In its
battle rage, it feels no pain and shows no mercy.
base_stats:
strength: 18
dexterity: 10
constitution: 16
intelligence: 6
wisdom: 6
charisma: 4
luck: 8
abilities:
- basic_attack
- cleave
- berserker_rage
- intimidating_shout
loot_table:
- item_id: orc_greataxe
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- item_id: orc_war_paint
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- item_id: beast_hide_armor
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.70
quantity_min: 15
quantity_max: 40
experience_reward: 80
gold_reward_min: 20
gold_reward_max: 50
difficulty: hard
tags:
- humanoid
- orc
- berserker
- large
base_damage: 15
crit_chance: 0.15
flee_chance: 0.30

View File

@@ -0,0 +1,52 @@
# Skeleton Warrior - Medium undead melee
# An animated skeleton wielding ancient weapons
enemy_id: skeleton_warrior
name: Skeleton Warrior
description: >
The animated remains of a long-dead soldier, held together by dark magic.
Its empty eye sockets glow with pale blue fire, and it wields a rusted
but deadly sword with unnatural precision. It knows no fear and feels no pain.
base_stats:
strength: 12
dexterity: 10
constitution: 10
intelligence: 4
wisdom: 6
charisma: 2
luck: 6
abilities:
- basic_attack
- shield_bash
- bone_rattle
loot_table:
- item_id: ancient_sword
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: bone_fragment
drop_chance: 0.80
quantity_min: 2
quantity_max: 5
- item_id: soul_essence
drop_chance: 0.10
quantity_min: 1
quantity_max: 1
experience_reward: 45
gold_reward_min: 0
gold_reward_max: 10
difficulty: medium
tags:
- undead
- skeleton
- armed
- fearless
base_damage: 9
crit_chance: 0.08
flee_chance: 0.50

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%})"
)

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,260 @@
"""
Enemy Loader Service - YAML-based enemy template loading.
This service loads enemy definitions from YAML files, providing a data-driven
approach to defining monsters and enemies for combat encounters.
"""
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from app.models.enemy import EnemyTemplate, EnemyDifficulty
from app.utils.logging import get_logger
logger = get_logger(__file__)
class EnemyLoader:
"""
Loads enemy templates from YAML configuration files.
This allows game designers to define enemies without touching code.
Enemy files are organized by difficulty in subdirectories.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the enemy loader.
Args:
data_dir: Path to directory containing enemy YAML files
Defaults to /app/data/enemies/
"""
if data_dir is None:
# Default to app/data/enemies relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "enemies")
self.data_dir = Path(data_dir)
self._enemy_cache: Dict[str, EnemyTemplate] = {}
self._loaded = False
logger.info("EnemyLoader initialized", data_dir=str(self.data_dir))
def load_enemy(self, enemy_id: str) -> Optional[EnemyTemplate]:
"""
Load a single enemy template by ID.
Args:
enemy_id: Unique enemy identifier
Returns:
EnemyTemplate instance or None if not found
"""
# Check cache first
if enemy_id in self._enemy_cache:
return self._enemy_cache[enemy_id]
# If not cached, try loading all enemies first
if not self._loaded:
self.load_all_enemies()
if enemy_id in self._enemy_cache:
return self._enemy_cache[enemy_id]
# Try loading from specific YAML file
yaml_file = self.data_dir / f"{enemy_id}.yaml"
if yaml_file.exists():
return self._load_from_file(yaml_file)
# Search in subdirectories
for subdir in self.data_dir.iterdir():
if subdir.is_dir():
yaml_file = subdir / f"{enemy_id}.yaml"
if yaml_file.exists():
return self._load_from_file(yaml_file)
logger.warning("Enemy not found", enemy_id=enemy_id)
return None
def _load_from_file(self, yaml_file: Path) -> Optional[EnemyTemplate]:
"""
Load an enemy template from a specific YAML file.
Args:
yaml_file: Path to the YAML file
Returns:
EnemyTemplate instance or None on error
"""
try:
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
enemy = EnemyTemplate.from_dict(data)
self._enemy_cache[enemy.enemy_id] = enemy
logger.debug("Enemy loaded", enemy_id=enemy.enemy_id, file=str(yaml_file))
return enemy
except Exception as e:
logger.error("Failed to load enemy file",
file=str(yaml_file),
error=str(e))
return None
def load_all_enemies(self) -> Dict[str, EnemyTemplate]:
"""
Load all enemy templates from the data directory.
Searches both the root directory and subdirectories for YAML files.
Returns:
Dictionary mapping enemy_id to EnemyTemplate instance
"""
if not self.data_dir.exists():
logger.warning("Enemy data directory not found", path=str(self.data_dir))
return {}
enemies = {}
# Load from root directory
for yaml_file in self.data_dir.glob("*.yaml"):
enemy = self._load_from_file(yaml_file)
if enemy:
enemies[enemy.enemy_id] = enemy
# Load from subdirectories (organized by difficulty)
for subdir in self.data_dir.iterdir():
if subdir.is_dir():
for yaml_file in subdir.glob("*.yaml"):
enemy = self._load_from_file(yaml_file)
if enemy:
enemies[enemy.enemy_id] = enemy
self._loaded = True
logger.info("All enemies loaded", count=len(enemies))
return enemies
def get_enemies_by_difficulty(
self,
difficulty: EnemyDifficulty
) -> List[EnemyTemplate]:
"""
Get all enemies matching a difficulty level.
Args:
difficulty: Difficulty level to filter by
Returns:
List of EnemyTemplate instances
"""
if not self._loaded:
self.load_all_enemies()
return [
enemy for enemy in self._enemy_cache.values()
if enemy.difficulty == difficulty
]
def get_enemies_by_tag(self, tag: str) -> List[EnemyTemplate]:
"""
Get all enemies with a specific tag.
Args:
tag: Tag to filter by (e.g., "undead", "beast", "humanoid")
Returns:
List of EnemyTemplate instances with that tag
"""
if not self._loaded:
self.load_all_enemies()
return [
enemy for enemy in self._enemy_cache.values()
if enemy.has_tag(tag)
]
def get_random_enemies(
self,
count: int = 1,
difficulty: Optional[EnemyDifficulty] = None,
tag: Optional[str] = None,
exclude_bosses: bool = True
) -> List[EnemyTemplate]:
"""
Get random enemies for encounter generation.
Args:
count: Number of enemies to select
difficulty: Optional difficulty filter
tag: Optional tag filter
exclude_bosses: Whether to exclude boss enemies
Returns:
List of randomly selected EnemyTemplate instances
"""
import random
if not self._loaded:
self.load_all_enemies()
# Build candidate list
candidates = list(self._enemy_cache.values())
# Apply filters
if difficulty:
candidates = [e for e in candidates if e.difficulty == difficulty]
if tag:
candidates = [e for e in candidates if e.has_tag(tag)]
if exclude_bosses:
candidates = [e for e in candidates if not e.is_boss()]
if not candidates:
logger.warning("No enemies match filters",
difficulty=difficulty.value if difficulty else None,
tag=tag)
return []
# Select random enemies (with replacement if needed)
if len(candidates) >= count:
return random.sample(candidates, count)
else:
# Not enough unique enemies, allow duplicates
return random.choices(candidates, k=count)
def clear_cache(self) -> None:
"""Clear the enemy cache, forcing reload on next access."""
self._enemy_cache.clear()
self._loaded = False
logger.debug("Enemy cache cleared")
def get_all_cached(self) -> Dict[str, EnemyTemplate]:
"""
Get all cached enemies.
Returns:
Dictionary of cached enemy templates
"""
if not self._loaded:
self.load_all_enemies()
return self._enemy_cache.copy()
# Global instance for convenience
_loader_instance: Optional[EnemyLoader] = None
def get_enemy_loader() -> EnemyLoader:
"""
Get the global EnemyLoader instance.
Returns:
Singleton EnemyLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = EnemyLoader()
return _loader_instance