Root Cause
When using combat abilities (like "smite"), the web frontend was calling GET /api/v1/abilities/{ability_id} to fetch ability details for display, but this endpoint didn't exist, causing a 404 error.
Additionally, after fixing that, the ability would execute but:
1. Modal onclick issue: The onclick="closeModal()" on ability buttons was removing the button from the DOM before HTMX could fire the request
2. Field name mismatch: The API returns mana_cost but the frontend expected mp_cost
3. Duplicate text in combat log: The web view was adding "You" as actor and damage separately, but the API message already contained both
4. Page not auto-refreshing: Various attempts to use HX-Trigger headers failed due to HTMX overwriting them
Fixes Made
1. Created /api/app/api/abilities.py - New abilities API endpoint with GET /api/v1/abilities and GET /api/v1/abilities/<ability_id>
2. Modified /api/app/__init__.py - Registered the new abilities blueprint
3. Modified /public_web/templates/game/partials/ability_modal.html - Changed onclick="closeModal()" to hx-on::before-request="closeModal()" so HTMX captures the request before modal closes
4. Modified /public_web/app/views/combat_views.py:
- Fixed mp_cost → mana_cost field name lookup
- Removed duplicate actor/damage from combat log entries (API message is self-contained)
- Added inline script to trigger page refresh after combat actions
5. Modified /public_web/templates/game/combat.html - Updated JavaScript for combat action handling (though final fix was server-side script injection)
This commit is contained in:
@@ -189,6 +189,11 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(quests_bp)
|
||||
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
|
||||
# from app.api import marketplace
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
|
||||
129
api/app/api/abilities.py
Normal file
129
api/app/api/abilities.py
Normal 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())
|
||||
@@ -145,27 +145,29 @@ def combat_action(session_id: str):
|
||||
# API returns data directly in result, not nested under 'action_result'
|
||||
log_entries = []
|
||||
|
||||
# Player action entry
|
||||
player_entry = {
|
||||
'actor': 'You',
|
||||
'message': result.get('message', f'used {action_type}'),
|
||||
'type': 'player',
|
||||
'is_crit': False
|
||||
}
|
||||
|
||||
# Add damage info if present
|
||||
# The API message is self-contained (includes actor name and damage)
|
||||
# Don't add separate actor/damage to avoid duplication
|
||||
message = result.get('message', f'Used {action_type}')
|
||||
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'):
|
||||
player_entry['heal'] = result.get('healing')
|
||||
player_entry['type'] = 'heal'
|
||||
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)
|
||||
|
||||
@@ -179,18 +181,32 @@ def combat_action(session_id: str):
|
||||
'type': 'system'
|
||||
})
|
||||
|
||||
# Return log entries HTML
|
||||
resp = make_response(render_template(
|
||||
# Check if it's now enemy's turn
|
||||
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',
|
||||
combat_log=log_entries
|
||||
))
|
||||
)
|
||||
|
||||
# Trigger enemy turn if it's no longer player's turn
|
||||
next_combatant = result.get('next_combatant_id')
|
||||
if next_combatant and not result.get('next_is_player', True):
|
||||
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||
# Add script to trigger page refresh after showing the action result
|
||||
# This is more reliable than headers which can be modified by HTMX
|
||||
refresh_script = '''
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1200);
|
||||
</script>
|
||||
'''
|
||||
|
||||
return resp
|
||||
return log_html + refresh_script
|
||||
|
||||
except APIError as 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_data = ability_response.get('result', {})
|
||||
|
||||
# Check availability
|
||||
mp_cost = ability_data.get('mp_cost', 0)
|
||||
# Check availability (API returns 'mana_cost', template uses 'mp_cost')
|
||||
mp_cost = ability_data.get('mana_cost', ability_data.get('mp_cost', 0))
|
||||
cooldown = cooldowns.get(ability_id, 0)
|
||||
available = current_mp >= mp_cost and cooldown == 0
|
||||
|
||||
|
||||
@@ -263,16 +263,23 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Handle player action triggering enemy turn
|
||||
// Handle player action - refresh page to update UI
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const response = event.detail.xhr;
|
||||
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)
|
||||
if (triggers.includes('enemyTurn') && !enemyTurnPending) {
|
||||
triggerEnemyTurn();
|
||||
// Check custom header for combat refresh signal
|
||||
const shouldRefresh = response.getResponseHeader('X-Combat-Refresh');
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
hx-target="#combat-log"
|
||||
hx-swap="beforeend"
|
||||
hx-disabled-elt="this"
|
||||
{% if not ability.available %}disabled{% endif %}
|
||||
onclick="closeModal()">
|
||||
hx-on::before-request="closeModal()"
|
||||
{% if not ability.available %}disabled{% endif %}>
|
||||
<span class="ability-icon">
|
||||
{% if ability.damage_type == 'fire' %}🔥
|
||||
{% elif ability.damage_type == 'ice' %}❄
|
||||
|
||||
Reference in New Issue
Block a user