Merge branch 'bugfix/combat-abilities-fix' into dev

This commit is contained in:
2025-11-29 19:05:54 -06:00
5 changed files with 192 additions and 35 deletions

View File

@@ -189,6 +189,11 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(quests_bp) app.register_blueprint(quests_bp)
logger.info("Quests API blueprint registered") logger.info("Quests API blueprint registered")
# Import and register Abilities API blueprint
from app.api.abilities import abilities_bp
app.register_blueprint(abilities_bp)
logger.info("Abilities API blueprint registered")
# TODO: Register additional blueprints as they are created # TODO: Register additional blueprints as they are created
# from app.api import marketplace # from app.api import marketplace
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace') # app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')

129
api/app/api/abilities.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Abilities API Blueprint
This module provides API endpoints for fetching ability information:
- List all available abilities
- Get details for a specific ability
"""
from flask import Blueprint
from app.models.abilities import AbilityLoader
from app.utils.response import (
success_response,
not_found_response,
)
from app.utils.logging import get_logger
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
abilities_bp = Blueprint('abilities', __name__, url_prefix='/api/v1/abilities')
# Initialize ability loader (singleton pattern)
_ability_loader = None
def get_ability_loader() -> AbilityLoader:
"""
Get the singleton AbilityLoader instance.
Returns:
AbilityLoader: The ability loader instance
"""
global _ability_loader
if _ability_loader is None:
_ability_loader = AbilityLoader()
return _ability_loader
# =============================================================================
# Ability Endpoints
# =============================================================================
@abilities_bp.route('', methods=['GET'])
def list_abilities():
"""
List all available abilities.
Returns all abilities defined in the system with their full details.
Returns:
{
"abilities": [
{
"ability_id": "smite",
"name": "Smite",
"description": "Call down holy light...",
"ability_type": "spell",
"base_power": 20,
"damage_type": "holy",
"mana_cost": 10,
"cooldown": 0,
...
},
...
],
"count": 5
}
"""
logger.info("Listing all abilities")
loader = get_ability_loader()
abilities = loader.load_all_abilities()
# Convert to list of dicts for JSON serialization
abilities_list = [ability.to_dict() for ability in abilities.values()]
logger.info("Abilities listed", count=len(abilities_list))
return success_response({
"abilities": abilities_list,
"count": len(abilities_list)
})
@abilities_bp.route('/<ability_id>', methods=['GET'])
def get_ability(ability_id: str):
"""
Get details for a specific ability.
Args:
ability_id: The unique identifier for the ability (e.g., "smite")
Returns:
{
"ability_id": "smite",
"name": "Smite",
"description": "Call down holy light to smite your enemies",
"ability_type": "spell",
"base_power": 20,
"damage_type": "holy",
"scaling_stat": "wisdom",
"scaling_factor": 0.5,
"mana_cost": 10,
"cooldown": 0,
"effects_applied": [],
"is_aoe": false,
"target_count": 1
}
Errors:
404: Ability not found
"""
logger.info("Getting ability", ability_id=ability_id)
loader = get_ability_loader()
ability = loader.load_ability(ability_id)
if ability is None:
logger.warning("Ability not found", ability_id=ability_id)
return not_found_response(
message=f"Ability '{ability_id}' not found"
)
logger.info("Ability retrieved", ability_id=ability_id, name=ability.name)
return success_response(ability.to_dict())

View File

@@ -145,27 +145,29 @@ def combat_action(session_id: str):
# API returns data directly in result, not nested under 'action_result' # API returns data directly in result, not nested under 'action_result'
log_entries = [] log_entries = []
# Player action entry # The API message is self-contained (includes actor name and damage)
player_entry = { # Don't add separate actor/damage to avoid duplication
'actor': 'You', message = result.get('message', f'Used {action_type}')
'message': result.get('message', f'used {action_type}'),
'type': 'player',
'is_crit': False
}
# Add damage info if present
damage_results = result.get('damage_results', []) damage_results = result.get('damage_results', [])
if damage_results:
for dmg in damage_results:
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
player_entry['is_crit'] = dmg.get('is_critical', False)
if player_entry['is_crit']:
player_entry['type'] = 'crit'
# Add healing info if present # Determine entry type based on damage results
entry_type = 'player'
is_crit = False
if damage_results:
is_crit = any(dmg.get('is_critical', False) for dmg in damage_results)
if is_crit:
entry_type = 'crit'
if result.get('healing'): if result.get('healing'):
player_entry['heal'] = result.get('healing') entry_type = 'heal'
player_entry['type'] = 'heal'
player_entry = {
'actor': '', # API message already includes character name
'message': message,
'type': entry_type,
'is_crit': is_crit
# Don't add 'damage' - it's already in the message
}
log_entries.append(player_entry) log_entries.append(player_entry)
@@ -179,18 +181,32 @@ def combat_action(session_id: str):
'type': 'system' 'type': 'system'
}) })
# Return log entries HTML # Check if it's now enemy's turn
resp = make_response(render_template( next_combatant = result.get('next_combatant_id')
next_is_player = result.get('next_is_player', True)
logger.info("combat_action_result",
next_combatant=next_combatant,
next_is_player=next_is_player,
combat_ended=combat_ended)
# Render log entries
log_html = render_template(
'game/partials/combat_log.html', 'game/partials/combat_log.html',
combat_log=log_entries combat_log=log_entries
)) )
# Trigger enemy turn if it's no longer player's turn # Add script to trigger page refresh after showing the action result
next_combatant = result.get('next_combatant_id') # This is more reliable than headers which can be modified by HTMX
if next_combatant and not result.get('next_is_player', True): refresh_script = '''
resp.headers['HX-Trigger'] = 'enemyTurn' <script>
setTimeout(function() {
window.location.reload();
}, 1200);
</script>
'''
return resp return log_html + refresh_script
except APIError as e: except APIError as e:
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
@@ -233,8 +249,8 @@ def combat_abilities(session_id: str):
ability_response = client.get(f'/api/v1/abilities/{ability_id}') ability_response = client.get(f'/api/v1/abilities/{ability_id}')
ability_data = ability_response.get('result', {}) ability_data = ability_response.get('result', {})
# Check availability # Check availability (API returns 'mana_cost', template uses 'mp_cost')
mp_cost = ability_data.get('mp_cost', 0) mp_cost = ability_data.get('mana_cost', ability_data.get('mp_cost', 0))
cooldown = cooldowns.get(ability_id, 0) cooldown = cooldowns.get(ability_id, 0)
available = current_mp >= mp_cost and cooldown == 0 available = current_mp >= mp_cost and cooldown == 0

View File

@@ -263,16 +263,23 @@
}, 1000); }, 1000);
} }
// Handle player action triggering enemy turn // Handle player action - refresh page to update UI
document.body.addEventListener('htmx:afterRequest', function(event) { document.body.addEventListener('htmx:afterRequest', function(event) {
const response = event.detail.xhr; const response = event.detail.xhr;
if (!response) return; if (!response) return;
const triggers = response.getResponseHeader('HX-Trigger') || ''; // Check response status - only refresh on success
if (response.status !== 200) return;
// Only trigger enemy turn from player actions (not from our fetch calls) // Check custom header for combat refresh signal
if (triggers.includes('enemyTurn') && !enemyTurnPending) { const shouldRefresh = response.getResponseHeader('X-Combat-Refresh');
triggerEnemyTurn(); console.log('X-Combat-Refresh header:', shouldRefresh);
if (shouldRefresh === 'true') {
// Short delay to let user see their action result
setTimeout(function() {
window.location.reload();
}, 1000);
} }
}); });

View File

@@ -16,8 +16,8 @@
hx-target="#combat-log" hx-target="#combat-log"
hx-swap="beforeend" hx-swap="beforeend"
hx-disabled-elt="this" hx-disabled-elt="this"
{% if not ability.available %}disabled{% endif %} hx-on::before-request="closeModal()"
onclick="closeModal()"> {% if not ability.available %}disabled{% endif %}>
<span class="ability-icon"> <span class="ability-icon">
{% if ability.damage_type == 'fire' %}&#128293; {% if ability.damage_type == 'fire' %}&#128293;
{% elif ability.damage_type == 'ice' %}&#10052; {% elif ability.damage_type == 'ice' %}&#10052;