Combat foundation complete
This commit is contained in:
@@ -36,7 +36,7 @@ def combat_view(session_id: str):
|
||||
# Check if combat is still active
|
||||
if not result.get('in_combat'):
|
||||
# Combat ended - redirect to game play
|
||||
return redirect(url_for('game.play', session_id=session_id))
|
||||
return redirect(url_for('game.play_session', session_id=session_id))
|
||||
|
||||
encounter = result.get('encounter') or {}
|
||||
combat_log = result.get('combat_log', [])
|
||||
@@ -171,9 +171,11 @@ def combat_action(session_id: str):
|
||||
|
||||
# Add any effect entries
|
||||
for effect in result.get('effects_applied', []):
|
||||
# API may use "name" or "effect" key for the effect name
|
||||
effect_name = effect.get('name') or effect.get('effect') or 'Unknown'
|
||||
log_entries.append({
|
||||
'actor': '',
|
||||
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
|
||||
'message': effect.get('message', f'Effect applied: {effect_name}'),
|
||||
'type': 'system'
|
||||
})
|
||||
|
||||
@@ -417,15 +419,25 @@ def combat_flee(session_id: str):
|
||||
result = response.get('result', {})
|
||||
|
||||
if result.get('success'):
|
||||
# Flee successful - redirect to play page
|
||||
return redirect(url_for('game.play_session', session_id=session_id))
|
||||
# Flee successful - use HX-Redirect for HTMX
|
||||
resp = make_response(f'''
|
||||
<div class="combat-log__entry combat-log__entry--system">
|
||||
<span class="log-message">{result.get('message', 'You fled from combat!')}</span>
|
||||
</div>
|
||||
''')
|
||||
resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id)
|
||||
return resp
|
||||
else:
|
||||
# Flee failed - return log entry
|
||||
return f'''
|
||||
# Flee failed - return log entry, trigger enemy turn
|
||||
resp = make_response(f'''
|
||||
<div class="combat-log__entry combat-log__entry--system">
|
||||
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
|
||||
</div>
|
||||
'''
|
||||
''')
|
||||
# Failed flee consumes turn, so trigger enemy turn if needed
|
||||
if not result.get('next_is_player', True):
|
||||
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||
return resp
|
||||
|
||||
except APIError as e:
|
||||
logger.error("flee_failed", session_id=session_id, error=str(e))
|
||||
@@ -468,18 +480,19 @@ def combat_enemy_turn(session_id: str):
|
||||
)
|
||||
|
||||
# Format enemy action for log
|
||||
action_result = result.get('action_result', {})
|
||||
# API returns ActionResult directly in result, not nested under action_result
|
||||
log_entries = [{
|
||||
'actor': action_result.get('actor_name', 'Enemy'),
|
||||
'message': action_result.get('message', 'attacks'),
|
||||
'actor': 'Enemy',
|
||||
'message': result.get('message', 'attacks'),
|
||||
'type': 'enemy',
|
||||
'is_crit': action_result.get('is_critical', False)
|
||||
'is_crit': False
|
||||
}]
|
||||
|
||||
# Add damage info
|
||||
damage_results = action_result.get('damage_results', [])
|
||||
# Add damage info - API returns total_damage, not damage
|
||||
damage_results = result.get('damage_results', [])
|
||||
if damage_results:
|
||||
log_entries[0]['damage'] = damage_results[0].get('damage')
|
||||
log_entries[0]['damage'] = damage_results[0].get('total_damage')
|
||||
log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False)
|
||||
|
||||
# Check if it's still enemy turn (multiple enemies)
|
||||
resp = make_response(render_template(
|
||||
|
||||
@@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play')
|
||||
DEFAULT_ACTIONS = {
|
||||
'free': [
|
||||
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
|
||||
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
|
||||
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
|
||||
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
|
||||
],
|
||||
@@ -718,6 +717,243 @@ def do_travel(session_id: str):
|
||||
return f'<div class="error">Travel failed: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/monster-modal')
|
||||
@require_auth
|
||||
def monster_modal(session_id: str):
|
||||
"""
|
||||
Get monster selection modal with encounter options.
|
||||
|
||||
Fetches random encounter groups appropriate for the current location
|
||||
and character level from the API.
|
||||
"""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
# Get encounter options from API
|
||||
response = client.get(f'/api/v1/combat/encounters?session_id={session_id}')
|
||||
result = response.get('result', {})
|
||||
|
||||
location_name = result.get('location_name', 'Unknown Area')
|
||||
encounters = result.get('encounters', [])
|
||||
|
||||
return render_template(
|
||||
'game/partials/monster_modal.html',
|
||||
session_id=session_id,
|
||||
location_name=location_name,
|
||||
encounters=encounters
|
||||
)
|
||||
|
||||
except APINotFoundError as e:
|
||||
# No enemies found for this location
|
||||
return render_template(
|
||||
'game/partials/monster_modal.html',
|
||||
session_id=session_id,
|
||||
location_name='this area',
|
||||
encounters=[]
|
||||
)
|
||||
except APIError as e:
|
||||
logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e))
|
||||
return f'''
|
||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">⚔️ Search for Monsters</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="error">Failed to search for monsters: {e}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/start', methods=['POST'])
|
||||
@require_auth
|
||||
def start_combat(session_id: str):
|
||||
"""
|
||||
Start combat with selected enemies.
|
||||
|
||||
Called when player selects an encounter from the monster modal.
|
||||
Initiates combat via API and redirects to combat UI.
|
||||
|
||||
If there's already an active combat session, shows a conflict modal
|
||||
allowing the user to resume or abandon the existing combat.
|
||||
"""
|
||||
from flask import make_response
|
||||
|
||||
client = get_api_client()
|
||||
|
||||
# Get enemy_ids from request
|
||||
# HTMX hx-vals sends as form data (not JSON), where arrays become multiple values
|
||||
if request.is_json:
|
||||
enemy_ids = request.json.get('enemy_ids', [])
|
||||
else:
|
||||
# Form data: array values come as multiple entries with the same key
|
||||
enemy_ids = request.form.getlist('enemy_ids')
|
||||
|
||||
if not enemy_ids:
|
||||
return '<div class="error">No enemies selected.</div>', 400
|
||||
|
||||
try:
|
||||
# Start combat via API
|
||||
response = client.post('/api/v1/combat/start', {
|
||||
'session_id': session_id,
|
||||
'enemy_ids': enemy_ids
|
||||
})
|
||||
result = response.get('result', {})
|
||||
encounter_id = result.get('encounter_id')
|
||||
|
||||
if not encounter_id:
|
||||
logger.error("combat_start_no_encounter_id", session_id=session_id)
|
||||
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
|
||||
|
||||
logger.info("combat_started_from_modal",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter_id,
|
||||
enemy_count=len(enemy_ids))
|
||||
|
||||
# Close modal and redirect to combat page
|
||||
resp = make_response('')
|
||||
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
|
||||
return resp
|
||||
|
||||
except APIError as e:
|
||||
# Check if this is an "already in combat" error
|
||||
error_str = str(e)
|
||||
if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str:
|
||||
# Fetch existing combat info and show conflict modal
|
||||
try:
|
||||
check_response = client.get(f'/api/v1/combat/{session_id}/check')
|
||||
combat_info = check_response.get('result', {})
|
||||
|
||||
if combat_info.get('has_active_combat'):
|
||||
return render_template(
|
||||
'game/partials/combat_conflict_modal.html',
|
||||
session_id=session_id,
|
||||
combat_info=combat_info,
|
||||
pending_enemy_ids=enemy_ids
|
||||
)
|
||||
except APIError:
|
||||
pass # Fall through to generic error
|
||||
|
||||
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
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/check', methods=['GET'])
|
||||
@require_auth
|
||||
def check_combat_status(session_id: str):
|
||||
"""
|
||||
Check if the session has an active combat.
|
||||
|
||||
Returns JSON with combat status that can be used by HTMX
|
||||
to decide whether to show the monster modal or conflict modal.
|
||||
"""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
response = client.get(f'/api/v1/combat/{session_id}/check')
|
||||
result = response.get('result', {})
|
||||
return result
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_check_combat", session_id=session_id, error=str(e))
|
||||
return {'has_active_combat': False, 'error': str(e)}
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/abandon', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_combat(session_id: str):
|
||||
"""
|
||||
Abandon an existing combat session.
|
||||
|
||||
Called when player chooses to abandon their current combat
|
||||
in order to start a fresh one.
|
||||
"""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
|
||||
result = response.get('result', {})
|
||||
|
||||
if result.get('success'):
|
||||
logger.info("combat_abandoned", session_id=session_id)
|
||||
# Return success - the frontend will then try to start new combat
|
||||
return render_template(
|
||||
'game/partials/combat_abandoned_success.html',
|
||||
session_id=session_id,
|
||||
message="Combat abandoned. You can now start a new encounter."
|
||||
)
|
||||
else:
|
||||
return '<div class="error">No active combat to abandon.</div>', 400
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e))
|
||||
return f'<div class="error">Failed to abandon combat: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/combat/abandon-and-start', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_and_start_combat(session_id: str):
|
||||
"""
|
||||
Abandon existing combat and start a new one in a single action.
|
||||
|
||||
This is a convenience endpoint that combines abandon + start
|
||||
for a smoother user experience in the conflict modal.
|
||||
"""
|
||||
from flask import make_response
|
||||
|
||||
client = get_api_client()
|
||||
|
||||
# Get enemy_ids from request
|
||||
if request.is_json:
|
||||
enemy_ids = request.json.get('enemy_ids', [])
|
||||
else:
|
||||
enemy_ids = request.form.getlist('enemy_ids')
|
||||
|
||||
if not enemy_ids:
|
||||
return '<div class="error">No enemies selected.</div>', 400
|
||||
|
||||
try:
|
||||
# First abandon the existing combat
|
||||
abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
|
||||
abandon_result = abandon_response.get('result', {})
|
||||
|
||||
if not abandon_result.get('success'):
|
||||
# No combat to abandon, but that's fine - proceed with start
|
||||
logger.info("no_combat_to_abandon", session_id=session_id)
|
||||
|
||||
# Now start the new combat
|
||||
start_response = client.post('/api/v1/combat/start', {
|
||||
'session_id': session_id,
|
||||
'enemy_ids': enemy_ids
|
||||
})
|
||||
result = start_response.get('result', {})
|
||||
encounter_id = result.get('encounter_id')
|
||||
|
||||
if not encounter_id:
|
||||
logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id)
|
||||
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
|
||||
|
||||
logger.info("combat_started_after_abandon",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter_id,
|
||||
enemy_count=len(enemy_ids))
|
||||
|
||||
# Close modal and redirect to combat page
|
||||
resp = make_response('')
|
||||
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
|
||||
return resp
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e))
|
||||
return f'<div class="error">Failed to start combat: {e}</div>', 500
|
||||
|
||||
|
||||
@game_bp.route('/session/<session_id>/npc/<npc_id>')
|
||||
@require_auth
|
||||
def npc_chat_page(session_id: str, npc_id: str):
|
||||
|
||||
Reference in New Issue
Block a user