combat testing and polishing in the dev console, many bug fixes

This commit is contained in:
2025-11-27 20:37:53 -06:00
parent 94c4ca9e95
commit dd92cf5991
45 changed files with 8157 additions and 1106 deletions

View File

@@ -380,3 +380,652 @@ def do_travel(session_id: str):
except APIError as e:
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
return f'<div class="error">Travel failed: {e}</div>', 500
# ===== Combat Test Endpoints =====
@dev_bp.route('/combat')
@require_auth
def combat_hub():
"""Combat testing hub - select character and enemies to start combat."""
client = get_api_client()
try:
# Get user's characters
characters_response = client.get('/api/v1/characters')
result = characters_response.get('result', {})
characters = result.get('characters', [])
# Get available enemy templates
enemies = []
try:
enemies_response = client.get('/api/v1/combat/enemies')
enemies = enemies_response.get('result', {}).get('enemies', [])
except (APINotFoundError, APIError):
# Enemies endpoint may not exist yet
pass
# Get all sessions to map characters to their sessions
sessions_in_combat = []
character_session_map = {} # character_id -> session_id
try:
sessions_response = client.get('/api/v1/sessions')
all_sessions = sessions_response.get('result', [])
for session in all_sessions:
# Map character to session (for dropdown)
char_id = session.get('character_id')
if char_id:
character_session_map[char_id] = session.get('session_id')
# Track sessions in combat (for resume list)
if session.get('in_combat') or session.get('game_state', {}).get('in_combat'):
sessions_in_combat.append(session)
except (APINotFoundError, APIError):
pass
# Add session_id to each character for the template
for char in characters:
char['session_id'] = character_session_map.get(char.get('character_id'))
return render_template(
'dev/combat.html',
characters=characters,
enemies=enemies,
sessions_in_combat=sessions_in_combat
)
except APIError as e:
logger.error("failed_to_load_combat_hub", error=str(e))
return render_template('dev/combat.html', characters=[], enemies=[], sessions_in_combat=[], error=str(e))
@dev_bp.route('/combat/start', methods=['POST'])
@require_auth
def start_combat():
"""Start a new combat encounter - returns redirect to combat session."""
client = get_api_client()
session_id = request.form.get('session_id')
enemy_ids = request.form.getlist('enemy_ids')
logger.info("start_combat called",
session_id=session_id,
enemy_ids=enemy_ids,
form_data=dict(request.form))
if not session_id:
return '<div class="error">No session selected</div>', 400
if not enemy_ids:
return '<div class="error">No enemies selected</div>', 400
try:
response = client.post('/api/v1/combat/start', {
'session_id': session_id,
'enemy_ids': enemy_ids
})
result = response.get('result', {})
# Return redirect script to combat session page
return f'''
<script>window.location.href = '/dev/combat/session/{session_id}';</script>
<div class="success">Combat started! Redirecting...</div>
'''
except APIError as e:
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to start combat: {e}</div>', 500
@dev_bp.route('/combat/session/<session_id>')
@require_auth
def combat_session(session_id: str):
"""Combat session debug interface - full 3-column layout."""
client = get_api_client()
try:
# Get combat state from API
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
# Check if combat is still active
if not result.get('in_combat'):
# Combat ended - redirect to combat index
return render_template('dev/combat.html',
message="Combat has ended. Start a new combat to continue.")
encounter = result.get('encounter') or {}
combat_log = result.get('combat_log', [])
# Get current turn combatant ID directly from API response
current_turn_id = encounter.get('current_turn')
turn_order = encounter.get('turn_order', [])
# Find player and determine if it's player's turn
is_player_turn = False
player_combatant = None
enemy_combatants = []
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
if combatant.get('combatant_id') == current_turn_id:
is_player_turn = True
else:
enemy_combatants.append(combatant)
# Format combat log entries for display
formatted_log = []
for entry in combat_log:
log_entry = {
'actor': entry.get('combatant_name', entry.get('actor', '')),
'message': entry.get('message', ''),
'damage': entry.get('damage'),
'heal': entry.get('healing'),
'is_crit': entry.get('is_critical', False),
'type': 'player' if entry.get('is_player', False) else 'enemy'
}
# Detect system messages
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
log_entry['type'] = 'system'
formatted_log.append(log_entry)
return render_template(
'dev/combat_session.html',
session_id=session_id,
encounter=encounter,
combat_log=formatted_log,
current_turn_id=current_turn_id,
is_player_turn=is_player_turn,
player_combatant=player_combatant,
enemy_combatants=enemy_combatants,
turn_order=turn_order,
raw_state=result
)
except APINotFoundError:
logger.warning("combat_not_found", session_id=session_id)
return render_template('dev/combat.html', error=f"No active combat for session {session_id}"), 404
except APIError as e:
logger.error("failed_to_load_combat_session", session_id=session_id, error=str(e))
return render_template('dev/combat.html', error=str(e)), 500
@dev_bp.route('/combat/<session_id>/state')
@require_auth
def combat_state(session_id: str):
"""Get combat state partial - returns refreshable state panel."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
# Check if combat is still active
if not result.get('in_combat'):
return '<div class="state-section"><h4>Combat Ended</h4><p>No active combat.</p></div>'
encounter = result.get('encounter') or {}
# Get current turn combatant ID directly from API response
current_turn_id = encounter.get('current_turn')
# Separate player and enemies
player_combatant = None
enemy_combatants = []
is_player_turn = False
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
if combatant.get('combatant_id') == current_turn_id:
is_player_turn = True
else:
enemy_combatants.append(combatant)
return render_template(
'dev/partials/combat_state.html',
session_id=session_id,
encounter=encounter,
player_combatant=player_combatant,
enemy_combatants=enemy_combatants,
current_turn_id=current_turn_id,
is_player_turn=is_player_turn
)
except APIError as e:
logger.error("failed_to_get_combat_state", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load state: {e}</div>', 500
@dev_bp.route('/combat/<session_id>/action', methods=['POST'])
@require_auth
def combat_action(session_id: str):
"""Execute a combat action - returns log entry HTML."""
client = get_api_client()
action_type = request.form.get('action_type', 'attack')
ability_id = request.form.get('ability_id')
item_id = request.form.get('item_id')
target_id = request.form.get('target_id')
try:
payload = {'action_type': action_type}
if ability_id:
payload['ability_id'] = ability_id
if item_id:
payload['item_id'] = item_id
if target_id:
payload['target_id'] = target_id
response = client.post(f'/api/v1/combat/{session_id}/action', payload)
result = response.get('result', {})
# Check if combat ended
combat_ended = result.get('combat_ended', False)
combat_status = result.get('combat_status')
if combat_ended:
# API returns lowercase status values: 'victory', 'defeat', 'fled'
status_lower = (combat_status or '').lower()
if status_lower == 'victory':
return render_template(
'dev/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
elif status_lower == 'defeat':
return render_template(
'dev/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0)
)
# Format action result for log
# API returns data directly in result, not nested under 'action_result'
log_entries = []
player_entry = {
'actor': 'You',
'message': result.get('message', f'used {action_type}'),
'type': 'player',
'is_crit': False
}
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'
if result.get('healing'):
player_entry['heal'] = result.get('healing')
player_entry['type'] = 'heal'
log_entries.append(player_entry)
for effect in result.get('effects_applied', []):
log_entries.append({
'actor': '',
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
'type': 'system'
})
# Return log entries with optional enemy turn trigger
from flask import make_response
resp = make_response(render_template(
'dev/partials/combat_debug_log.html',
combat_log=log_entries
))
# Trigger enemy turn if needed
if result.get('next_combatant_id') and not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Action failed: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/enemy-turn', methods=['POST'])
@require_auth
def combat_enemy_turn(session_id: str):
"""Execute enemy turn - returns log entry HTML."""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {})
result = response.get('result', {})
# Check if combat ended
combat_ended = result.get('combat_ended', False)
combat_status = result.get('combat_status')
if combat_ended:
# API returns lowercase status values: 'victory', 'defeat', 'fled'
status_lower = (combat_status or '').lower()
if status_lower == 'victory':
return render_template(
'dev/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
elif status_lower == 'defeat':
return render_template(
'dev/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0)
)
# Format enemy action for log
# The API returns the action result directly with a complete message
damage_results = result.get('damage_results', [])
is_crit = damage_results[0].get('is_critical', False) if damage_results else False
log_entries = [{
'actor': '', # Message already contains the actor name
'message': result.get('message', 'Enemy attacks!'),
'type': 'crit' if is_crit else 'enemy',
'is_crit': is_crit,
'damage': damage_results[0].get('total_damage') if damage_results else None
}]
from flask import make_response
resp = make_response(render_template(
'dev/partials/combat_debug_log.html',
combat_log=log_entries
))
# Trigger another enemy turn if needed
if result.get('next_combatant_id') and not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("enemy_turn_failed", session_id=session_id, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Enemy turn error: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/abilities')
@require_auth
def combat_abilities(session_id: str):
"""Get abilities modal for combat."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
encounter = result.get('encounter', {})
player_combatant = None
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
break
abilities = []
if player_combatant:
ability_ids = player_combatant.get('abilities', [])
current_mp = player_combatant.get('current_mp', 0)
cooldowns = player_combatant.get('cooldowns', {})
for ability_id in ability_ids:
try:
ability_response = client.get(f'/api/v1/abilities/{ability_id}')
ability_data = ability_response.get('result', {})
mp_cost = ability_data.get('mp_cost', 0)
cooldown = cooldowns.get(ability_id, 0)
available = current_mp >= mp_cost and cooldown == 0
abilities.append({
'id': ability_id,
'name': ability_data.get('name', ability_id),
'description': ability_data.get('description', ''),
'mp_cost': mp_cost,
'cooldown': cooldown,
'max_cooldown': ability_data.get('cooldown', 0),
'damage_type': ability_data.get('damage_type'),
'effect_type': ability_data.get('effect_type'),
'available': available
})
except (APINotFoundError, APIError):
abilities.append({
'id': ability_id,
'name': ability_id.replace('_', ' ').title(),
'description': '',
'mp_cost': 0,
'cooldown': cooldowns.get(ability_id, 0),
'max_cooldown': 0,
'available': True
})
return render_template(
'dev/partials/ability_modal.html',
session_id=session_id,
abilities=abilities
)
except APIError as e:
logger.error("failed_to_load_abilities", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content">
<h3>Select Ability</h3>
<div class="error">Failed to load abilities: {e}</div>
<button class="modal-close" onclick="closeModal()">Close</button>
</div>
</div>
'''
@dev_bp.route('/combat/<session_id>/items')
@require_auth
def combat_items(session_id: str):
"""Get combat items bottom sheet (consumables only)."""
client = get_api_client()
try:
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
consumables = []
if character_id:
try:
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
for item in inventory:
item_type = item.get('item_type', item.get('type', ''))
if item_type == 'consumable' or item.get('usable_in_combat', False):
consumables.append({
'item_id': item.get('item_id'),
'name': item.get('name', 'Unknown Item'),
'description': item.get('description', ''),
'effects_on_use': item.get('effects_on_use', []),
'rarity': item.get('rarity', 'common')
})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e))
return render_template(
'dev/partials/combat_items_sheet.html',
session_id=session_id,
consumables=consumables,
has_consumables=len(consumables) > 0
)
except APIError as e:
logger.error("failed_to_load_items", session_id=session_id, error=str(e))
return f'''
<div class="combat-items-sheet open">
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
<div class="error">Failed to load items: {e}</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
'''
@dev_bp.route('/combat/<session_id>/items/<item_id>/detail')
@require_auth
def combat_item_detail(session_id: str, item_id: str):
"""Get item detail for combat bottom sheet."""
client = get_api_client()
try:
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
item = None
if character_id:
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
for inv_item in inventory:
if inv_item.get('item_id') == item_id:
item = inv_item
break
if not item:
return '<p>Item not found</p>', 404
effect_desc = item.get('description', 'Use this item')
effects = item.get('effects_on_use', [])
if effects:
effect_parts = []
for effect in effects:
if effect.get('stat') == 'hp':
effect_parts.append(f"Restores {effect.get('value', 0)} HP")
elif effect.get('stat') == 'mp':
effect_parts.append(f"Restores {effect.get('value', 0)} MP")
elif effect.get('name'):
effect_parts.append(effect.get('name'))
if effect_parts:
effect_desc = ', '.join(effect_parts)
return f'''
<div class="detail-info">
<div class="detail-name">{item.get('name', 'Item')}</div>
<div class="detail-effect">{effect_desc}</div>
</div>
<button class="use-btn"
hx-post="/dev/combat/{session_id}/action"
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeCombatSheet()">
Use
</button>
'''
except APIError as e:
logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e))
return f'<p>Failed to load item: {e}</p>', 500
@dev_bp.route('/combat/<session_id>/end', methods=['POST'])
@require_auth
def force_end_combat(session_id: str):
"""Force end combat (debug action)."""
client = get_api_client()
victory = request.form.get('victory', 'true').lower() == 'true'
try:
response = client.post(f'/api/v1/combat/{session_id}/end', {'victory': victory})
result = response.get('result', {})
if victory:
return render_template(
'dev/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
else:
return render_template(
'dev/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0)
)
except APIError as e:
logger.error("failed_to_end_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to end combat: {e}</div>', 500
@dev_bp.route('/combat/<session_id>/reset-hp-mp', methods=['POST'])
@require_auth
def reset_hp_mp(session_id: str):
"""Reset player HP and MP to full (debug action)."""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/debug/reset-hp-mp', {})
result = response.get('result', {})
return f'''
<div class="log-entry log-entry--heal">
<span class="log-message">HP and MP restored to full! (HP: {result.get('current_hp', '?')}/{result.get('max_hp', '?')}, MP: {result.get('current_mp', '?')}/{result.get('max_mp', '?')})</span>
</div>
'''
except APIError as e:
logger.error("failed_to_reset_hp_mp", session_id=session_id, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Failed to reset HP/MP: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/log')
@require_auth
def combat_log(session_id: str):
"""Get full combat log."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
combat_log_data = result.get('combat_log', [])
formatted_log = []
for entry in combat_log_data:
log_entry = {
'actor': entry.get('combatant_name', entry.get('actor', '')),
'message': entry.get('message', ''),
'damage': entry.get('damage'),
'heal': entry.get('healing'),
'is_crit': entry.get('is_critical', False),
'type': 'player' if entry.get('is_player', False) else 'enemy'
}
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
log_entry['type'] = 'system'
formatted_log.append(log_entry)
return render_template(
'dev/partials/combat_debug_log.html',
combat_log=formatted_log
)
except APIError as e:
logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e))
return '<div class="error">Failed to load combat log</div>', 500