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:
@@ -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
729
api/app/api/combat.py
Normal 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"
|
||||
)
|
||||
55
api/app/data/enemies/bandit.yaml
Normal file
55
api/app/data/enemies/bandit.yaml
Normal 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
|
||||
52
api/app/data/enemies/dire_wolf.yaml
Normal file
52
api/app/data/enemies/dire_wolf.yaml
Normal 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
|
||||
45
api/app/data/enemies/goblin.yaml
Normal file
45
api/app/data/enemies/goblin.yaml
Normal 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
|
||||
52
api/app/data/enemies/goblin_shaman.yaml
Normal file
52
api/app/data/enemies/goblin_shaman.yaml
Normal 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
|
||||
58
api/app/data/enemies/orc_berserker.yaml
Normal file
58
api/app/data/enemies/orc_berserker.yaml
Normal 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
|
||||
52
api/app/data/enemies/skeleton_warrior.yaml
Normal file
52
api/app/data/enemies/skeleton_warrior.yaml
Normal 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
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%})"
|
||||
)
|
||||
|
||||
1068
api/app/services/combat_service.py
Normal file
1068
api/app/services/combat_service.py
Normal file
File diff suppressed because it is too large
Load Diff
594
api/app/services/damage_calculator.py
Normal file
594
api/app/services/damage_calculator.py
Normal 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
|
||||
260
api/app/services/enemy_loader.py
Normal file
260
api/app/services/enemy_loader.py
Normal 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
|
||||
Reference in New Issue
Block a user