Combat foundation complete

This commit is contained in:
2025-11-27 22:18:58 -06:00
parent dd92cf5991
commit 6d3fb63355
33 changed files with 1870 additions and 85 deletions

View File

@@ -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()">&times;</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):