Compare commits

..

3 Commits

Author SHA1 Message Date
70b2b0f124 fixing leveling xp reporting 2025-11-30 18:20:40 -06:00
805d04cf4e Merge branch 'bugfix/combat-abilities-fix' into dev 2025-11-29 19:05:54 -06:00
f9e463bfc6 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)
2025-11-29 19:05:39 -06:00
11 changed files with 323 additions and 47 deletions

View File

@@ -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
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

@@ -35,7 +35,8 @@ class Character:
player_class: Character's class (determines base stats and skill trees)
origin: Character's backstory origin (saved for AI DM narrative hooks)
level: Current level
experience: Current XP points
experience: Current XP progress toward next level (resets on level-up)
total_xp: Cumulative XP earned across all levels (never decreases)
base_stats: Base stats (from class + level-ups)
unlocked_skills: List of skill_ids that have been unlocked
inventory: All items the character owns
@@ -53,7 +54,8 @@ class Character:
player_class: PlayerClass
origin: Origin
level: int = 1
experience: int = 0
experience: int = 0 # Current level progress (resets on level-up)
total_xp: int = 0 # Cumulative XP (never decreases)
# Stats and progression
base_stats: Stats = field(default_factory=Stats)
@@ -315,6 +317,9 @@ class Character:
"""
Add experience points and check for level up.
Updates both current level progress (experience) and cumulative total (total_xp).
The cumulative total never decreases, providing players a sense of overall progression.
Args:
xp: Amount of experience to add
@@ -322,6 +327,7 @@ class Character:
True if character leveled up, False otherwise
"""
self.experience += xp
self.total_xp += xp # Track cumulative XP (never decreases)
required_xp = self._calculate_xp_for_next_level()
if self.experience >= required_xp:
@@ -334,15 +340,18 @@ class Character:
"""
Level up the character.
- Increases level
- Resets experience to overflow amount
- Increases level by 1
- Resets experience to overflow amount (XP beyond requirement carries over)
- Preserves total_xp (cumulative XP is never modified here)
- Grants 1 skill point (level - unlocked_skills count)
- Could grant stat increases (future enhancement)
"""
required_xp = self._calculate_xp_for_next_level()
overflow_xp = self.experience - required_xp
self.level += 1
self.experience = overflow_xp
self.experience = overflow_xp # Reset current level progress
# total_xp remains unchanged - it's cumulative and never decreases
# Future: Apply stat increases based on class
# For now, stats are increased manually via skill points
@@ -359,6 +368,19 @@ class Character:
"""
return int(100 * (self.level ** 1.5))
@property
def xp_to_next_level(self) -> int:
"""
Get XP remaining until next level.
This is a computed property for UI display showing progress bars
and "X/Y XP to next level" displays.
Returns:
Amount of XP needed to reach next level
"""
return self._calculate_xp_for_next_level() - self.experience
def to_dict(self) -> Dict[str, Any]:
"""
Serialize character to dictionary for JSON storage.
@@ -374,6 +396,9 @@ class Character:
"origin": self.origin.to_dict(),
"level": self.level,
"experience": self.experience,
"total_xp": self.total_xp,
"xp_to_next_level": self.xp_to_next_level,
"xp_required_for_next_level": self._calculate_xp_for_next_level(),
"base_stats": self.base_stats.to_dict(),
"unlocked_skills": self.unlocked_skills,
"inventory": [item.to_dict() for item in self.inventory],
@@ -465,6 +490,7 @@ class Character:
origin=origin,
level=data.get("level", 1),
experience=data.get("experience", 0),
total_xp=data.get("total_xp", 0), # Default 0 for legacy data
base_stats=base_stats,
unlocked_skills=data.get("unlocked_skills", []),
inventory=inventory,

View File

@@ -1261,7 +1261,12 @@ class CombatService:
xp_per_player = rewards.experience // max(1, len(player_combatants))
gold_per_player = rewards.gold // max(1, len(player_combatants))
for player in player_combatants:
# Distribute items evenly among players
# For simplicity, give all items to each player in solo mode,
# or distribute round-robin in multiplayer
items_to_distribute = [Item.from_dict(item_dict) for item_dict in rewards.items]
for i, player in enumerate(player_combatants):
if session.is_solo():
char_id = session.solo_character_id
else:
@@ -1278,6 +1283,18 @@ class CombatService:
# Add gold
character.gold += gold_per_player
# Distribute items
if session.is_solo():
# Solo mode: give all items to the character
for item in items_to_distribute:
character.add_item(item)
else:
# Multiplayer: distribute items round-robin
# Player i gets items at indices i, i+n, i+2n, etc.
for idx, item in enumerate(items_to_distribute):
if idx % len(player_combatants) == i:
character.add_item(item)
# Save character
self.character_service.update_character(character, user_id)

View File

@@ -936,7 +936,8 @@ power = fireball.calculate_power(caster_stats)
| `name` | str | Character name |
| `player_class` | PlayerClass | Character class |
| `level` | int | Current level |
| `experience` | int | XP points |
| `experience` | int | Current XP progress toward next level (resets on level-up) |
| `total_xp` | int | Cumulative XP earned across all levels (never decreases) |
| `stats` | Stats | Current stats |
| `unlocked_skills` | List[str] | Unlocked skill_ids |
| `inventory` | List[Item] | All items |
@@ -945,10 +946,17 @@ power = fireball.calculate_power(caster_stats)
| `active_quests` | List[str] | Quest IDs |
| `discovered_locations` | List[str] | Location IDs |
**Computed Properties:**
- `xp_to_next_level` - XP remaining until next level (calculated: `required_xp - experience`)
- `current_hp` / `max_hp` - Health points (calculated from constitution)
- `defense` / `resistance` - Damage reduction (calculated from stats)
**Methods:**
- `to_dict()` - Serialize to dictionary for JSON storage
- `from_dict(data)` - Deserialize from dictionary
- `to_dict()` - Serialize to dictionary for JSON storage (includes computed fields)
- `from_dict(data)` - Deserialize from dictionary (handles legacy data)
- `get_effective_stats(active_effects)` - **THE CRITICAL METHOD** - Calculate final stats
- `add_experience(xp)` - Add XP and check for level-up (updates both `experience` and `total_xp`)
- `level_up()` - Level up character (resets `experience`, preserves `total_xp`)
**get_effective_stats() Details:**

View File

@@ -401,6 +401,12 @@ XP Required = 100 × (current_level ^ 1.5)
- Level up triggers automatically when threshold reached
- Base stats remain constant (progression via skill trees & equipment)
**XP Tracking:**
- **`experience`**: Current progress toward next level (0 to required XP, resets on level-up)
- **`total_xp`**: Cumulative XP earned across all levels (never decreases)
- **UI Display**: Shows both values (e.g., "Total XP: 150 | Progress: 50/282 to Level 3")
- **Legacy data**: Characters without `total_xp` default to 0, will track from next XP gain
**Implementation:**
- Leveling logic lives in `Character` model (`add_experience()`, `level_up()` methods)
- No separate service needed (OOP design pattern)

View File

@@ -324,6 +324,7 @@ def test_add_experience_no_level_up(basic_character):
assert leveled_up == False
assert basic_character.level == 1
assert basic_character.experience == 50
assert basic_character.total_xp == 50 # Cumulative XP tracked
def test_add_experience_with_level_up(basic_character):
@@ -333,7 +334,8 @@ def test_add_experience_with_level_up(basic_character):
assert leveled_up == True
assert basic_character.level == 2
assert basic_character.experience == 0 # Reset
assert basic_character.experience == 0 # Current level progress resets
assert basic_character.total_xp == 100 # Cumulative XP preserved
def test_add_experience_with_overflow(basic_character):
@@ -343,7 +345,48 @@ def test_add_experience_with_overflow(basic_character):
assert leveled_up == True
assert basic_character.level == 2
assert basic_character.experience == 50 # Overflow
assert basic_character.experience == 50 # Overflow preserved
assert basic_character.total_xp == 150 # All XP tracked cumulatively
def test_xp_to_next_level_property(basic_character):
"""Test xp_to_next_level property calculation."""
# At level 1 with 0 XP, need 100 to level up
assert basic_character.xp_to_next_level == 100
# Add 30 XP
basic_character.add_experience(30)
assert basic_character.xp_to_next_level == 70 # 100 - 30
# Add 70 more to level up
basic_character.add_experience(70)
assert basic_character.level == 2
assert basic_character.experience == 0
# At level 2, need 282 XP to level up
assert basic_character.xp_to_next_level == 282
def test_total_xp_never_decreases(basic_character):
"""Test that total_xp is cumulative and never decreases."""
# Start at 0
assert basic_character.total_xp == 0
# Add XP multiple times
basic_character.add_experience(50)
assert basic_character.total_xp == 50
basic_character.add_experience(50)
assert basic_character.total_xp == 100
# Should have leveled up to level 2
assert basic_character.level == 2
assert basic_character.experience == 0 # Current progress reset
# Add more XP
basic_character.add_experience(100)
assert basic_character.total_xp == 200 # Still cumulative
# Even though experience resets on level-up, total_xp keeps growing
assert basic_character.total_xp >= basic_character.experience
def test_xp_calculation(basic_origin):
@@ -386,6 +429,7 @@ def test_character_serialization(basic_character):
basic_character.gold = 500
basic_character.level = 3
basic_character.experience = 100
basic_character.total_xp = 500 # Manually set for test
data = basic_character.to_dict()
@@ -394,7 +438,13 @@ def test_character_serialization(basic_character):
assert data["name"] == "Test Hero"
assert data["level"] == 3
assert data["experience"] == 100
assert data["total_xp"] == 500
assert data["gold"] == 500
# Check computed fields
assert "xp_to_next_level" in data
assert "xp_required_for_next_level" in data
assert data["xp_required_for_next_level"] == 519 # Level 3 requires 519 XP
assert data["xp_to_next_level"] == 419 # 519 - 100
def test_character_deserialization(basic_player_class, basic_origin):

View File

@@ -636,7 +636,15 @@ class TestRewardsCalculation:
# Mock loot service to return mock items
mock_item = Mock()
mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1}
mock_item.to_dict.return_value = {
"item_id": "test_sword",
"name": "Test Sword",
"item_type": "weapon",
"rarity": "common",
"description": "A test sword",
"value": 10,
"damage": 5,
}
service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
mock_session = Mock()
@@ -647,6 +655,7 @@ class TestRewardsCalculation:
mock_char.level = 1
mock_char.experience = 0
mock_char.gold = 0
mock_char.add_item = Mock() # Mock the add_item method
service.character_service.get_character.return_value = mock_char
service.character_service.update_character = Mock()
@@ -655,3 +664,6 @@ class TestRewardsCalculation:
assert rewards.experience == 50
assert rewards.gold == 25
assert len(rewards.items) == 1
# Verify items were added to character inventory
assert mock_char.add_item.called, "Items should be added to character inventory"
assert mock_char.add_item.call_count == 1, "Should add 1 item to inventory"

View File

@@ -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

View File

@@ -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);
}
});

View File

@@ -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' %}&#128293;
{% elif ability.damage_type == 'ice' %}&#10052;