Files
Code_of_Conquest/public_web/app/views/game_views.py

2197 lines
81 KiB
Python

"""
Production game views for the play screen.
Provides the main gameplay interface with 3-column layout:
- Left: Character stats + action buttons
- Middle: Narrative + location context
- Right: Accordions for history, quests, NPCs, map
"""
from flask import Blueprint, render_template, request, redirect, url_for
import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError
from ..utils.auth import require_auth_web as require_auth, get_current_user
logger = structlog.get_logger(__name__)
game_bp = Blueprint('game', __name__, url_prefix='/play')
# ===== Action Definitions =====
# Actions organized by tier - context filtering happens in template
# These are static definitions, available actions come from API session state
DEFAULT_ACTIONS = {
'free': [
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
{'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}
],
'premium': [
{'prompt_id': 'investigate_suspicious', 'display_text': 'Investigate Suspicious Activity', 'icon': 'magnifying_glass', 'context': ['any']},
{'prompt_id': 'follow_lead', 'display_text': 'Follow a Lead', 'icon': 'footprints', 'context': ['any']},
{'prompt_id': 'make_camp', 'display_text': 'Make Camp', 'icon': 'campfire', 'context': ['wilderness'], 'cooldown': 5}
],
'elite': [
{'prompt_id': 'consult_texts', 'display_text': 'Consult Ancient Texts', 'icon': 'book', 'context': ['library', 'town'], 'cooldown': 3},
{'prompt_id': 'commune_nature', 'display_text': 'Commune with Nature', 'icon': 'leaf', 'context': ['wilderness'], 'cooldown': 4},
{'prompt_id': 'seek_audience', 'display_text': 'Seek Audience with Authorities', 'icon': 'crown', 'context': ['town'], 'cooldown': 5}
]
}
def _get_user_tier(client) -> str:
"""Get user's subscription tier from API or session."""
try:
# Try to get user info which includes tier
user_response = client.get('/api/v1/auth/me')
user_data = user_response.get('result', {})
return user_data.get('tier', 'free')
except (APIError, APINotFoundError):
# Default to free tier if we can't determine
return 'free'
def _build_location_from_game_state(game_state: dict) -> dict:
"""Build location dict from game_state data."""
return {
'location_id': game_state.get('current_location_id') or game_state.get('current_location'),
'name': game_state.get('current_location_name', game_state.get('current_location', 'Unknown')),
'location_type': game_state.get('location_type', 'unknown'),
'region': game_state.get('region', ''),
'description': game_state.get('location_description', ''),
'ambient_description': game_state.get('ambient_description', '')
}
def _build_character_from_api(char_data: dict) -> dict:
"""
Build character dict from API character response.
Always returns a dict with all required fields, using sensible defaults
if the API data is incomplete or empty.
"""
if not char_data:
char_data = {}
# Extract stats from base_stats or stats, with defaults
stats = char_data.get('base_stats', char_data.get('stats', {}))
if not stats:
stats = {
'strength': 10,
'dexterity': 10,
'constitution': 10,
'intelligence': 10,
'wisdom': 10,
'charisma': 10
}
# Calculate HP/MP - these may come from different places
# For now use defaults based on level/constitution
level = char_data.get('level', 1)
constitution = stats.get('constitution', 10)
intelligence = stats.get('intelligence', 10)
# Simple HP/MP calculation (can be refined based on game rules)
max_hp = max(1, 50 + (level * 10) + ((constitution - 10) * level))
max_mp = max(1, 20 + (level * 5) + ((intelligence - 10) * level // 2))
# Get class name from various possible locations
class_name = 'Unknown'
if char_data.get('player_class'):
class_name = char_data['player_class'].get('name', 'Unknown')
elif char_data.get('class_name'):
class_name = char_data['class_name']
elif char_data.get('class'):
class_name = char_data['class'].replace('_', ' ').title()
return {
'character_id': char_data.get('character_id', ''),
'name': char_data.get('name', 'Unknown Hero'),
'class_name': class_name,
'level': level,
'current_hp': char_data.get('current_hp', max_hp),
'max_hp': char_data.get('max_hp', max_hp),
'current_mp': char_data.get('current_mp', max_mp),
'max_mp': char_data.get('max_mp', max_mp),
'stats': stats,
'equipped': char_data.get('equipped', {}),
'inventory': char_data.get('inventory', []),
'gold': char_data.get('gold', 0),
'experience': char_data.get('experience', 0),
'unlocked_skills': char_data.get('unlocked_skills', [])
}
# ===== Main Routes =====
@game_bp.route('/session/<session_id>')
@require_auth
def play_session(session_id: str):
"""
Production play screen for a game session.
Displays 3-column layout with character panel, narrative area,
and sidebar accordions for history/quests/NPCs/map.
"""
client = get_api_client()
try:
# Get session state (includes game_state with location info)
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
# Extract game state and build location info
game_state = session_data.get('game_state', {})
location = _build_location_from_game_state(game_state)
# Get character details - always build a valid character dict
character_id = session_data.get('character_id')
char_data = {}
if character_id:
try:
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_character", character_id=character_id, error=str(e))
# Always build character with defaults for any missing fields
character = _build_character_from_api(char_data)
# Get session history (last DM response for display)
history = []
dm_response = "Your adventure awaits..."
try:
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=10')
history_data = history_response.get('result', {})
history = history_data.get('history', [])
# Get the most recent DM response for the main narrative panel
if history:
dm_response = history[0].get('dm_response', dm_response)
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_history", session_id=session_id, error=str(e))
# Get NPCs at current location
npcs = []
current_location_id = location.get('location_id')
if current_location_id:
try:
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}')
npcs = npcs_response.get('result', {}).get('npcs', [])
except (APINotFoundError, APIError) as e:
logger.debug("no_npcs_at_location", location_id=current_location_id, error=str(e))
# Get available travel destinations (discovered locations)
discovered_locations = []
try:
travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}')
travel_result = travel_response.get('result', {})
discovered_locations = travel_result.get('available_locations', [])
# Mark current location
for loc in discovered_locations:
loc['is_current'] = loc.get('location_id') == current_location_id
except (APINotFoundError, APIError) as e:
logger.debug("failed_to_load_travel_destinations", session_id=session_id, error=str(e))
# Get quests (from character's active_quests or session)
quests = game_state.get('active_quests', [])
# If quests are just IDs, we could expand them, but for now use what we have
# Get user tier
user_tier = _get_user_tier(client)
# Fetch usage info for daily turn limits
usage_info = {}
try:
usage_response = client.get("/api/v1/usage")
usage_info = usage_response.get('result', {})
except (APINotFoundError, APIError) as e:
logger.debug("could_not_fetch_usage_info", error=str(e))
# Build session object for template
session = {
'session_id': session_id,
'turn_number': session_data.get('turn_number', 0),
'status': session_data.get('status', 'active')
}
return render_template(
'game/play.html',
session_id=session_id,
session=session,
character=character,
location=location,
dm_response=dm_response,
history=history,
quests=quests,
npcs=npcs,
discovered_locations=discovered_locations,
actions=DEFAULT_ACTIONS,
user_tier=user_tier,
# Usage display variables
remaining=usage_info.get('remaining', 0),
daily_limit=usage_info.get('daily_limit', 0),
is_limited=usage_info.get('is_limited', False),
is_unlimited=usage_info.get('is_unlimited', False),
reset_time=usage_info.get('reset_time', '')
)
except APINotFoundError:
logger.warning("session_not_found", session_id=session_id)
return render_template('errors/404.html', message=f"Session {session_id} not found"), 404
except APIError as e:
logger.error("failed_to_load_play_session", session_id=session_id, error=str(e))
return render_template('errors/500.html', message=str(e)), 500
# ===== Partial Refresh Routes =====
@game_bp.route('/session/<session_id>/character-panel')
@require_auth
def character_panel(session_id: str):
"""Refresh character stats and actions panel."""
client = get_api_client()
try:
# Get session to find character and location
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
location = _build_location_from_game_state(game_state)
# Get character - always build valid character dict
char_data = {}
character_id = session_data.get('character_id')
if character_id:
try:
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
except (APINotFoundError, APIError):
pass
character = _build_character_from_api(char_data)
user_tier = _get_user_tier(client)
# Fetch usage info for daily turn limits
usage_info = {}
try:
usage_response = client.get("/api/v1/usage")
usage_info = usage_response.get('result', {})
except (APINotFoundError, APIError) as e:
logger.debug("could_not_fetch_usage_info", error=str(e))
return render_template(
'game/partials/character_panel.html',
session_id=session_id,
character=character,
location=location,
actions=DEFAULT_ACTIONS,
user_tier=user_tier,
# Usage display variables
remaining=usage_info.get('remaining', 0),
daily_limit=usage_info.get('daily_limit', 0),
is_limited=usage_info.get('is_limited', False),
is_unlimited=usage_info.get('is_unlimited', False),
reset_time=usage_info.get('reset_time', '')
)
except APIError as e:
logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load character panel: {e}</div>', 500
@game_bp.route('/session/<session_id>/narrative')
@require_auth
def narrative_panel(session_id: str):
"""Refresh narrative content panel."""
client = get_api_client()
try:
# Get session state
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
location = _build_location_from_game_state(game_state)
# Get latest DM response from history
dm_response = "Your adventure awaits..."
try:
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=1')
history_data = history_response.get('result', {})
history = history_data.get('history', [])
if history:
dm_response = history[0].get('dm_response', dm_response)
except (APINotFoundError, APIError):
pass
session = {
'session_id': session_id,
'turn_number': session_data.get('turn_number', 0),
'status': session_data.get('status', 'active')
}
return render_template(
'game/partials/narrative_panel.html',
session_id=session_id,
session=session,
location=location,
dm_response=dm_response
)
except APIError as e:
logger.error("failed_to_refresh_narrative", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load narrative: {e}</div>', 500
@game_bp.route('/session/<session_id>/history')
@require_auth
def history_accordion(session_id: str):
"""Refresh history accordion content."""
client = get_api_client()
try:
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=20')
history_data = history_response.get('result', {})
history = history_data.get('history', [])
return render_template(
'game/partials/sidebar_history.html',
session_id=session_id,
history=history
)
except APIError as e:
logger.error("failed_to_refresh_history", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load history: {e}</div>', 500
@game_bp.route('/session/<session_id>/quests')
@require_auth
def quests_accordion(session_id: str):
"""
Refresh quests accordion content.
Fetches full quest data with progress from the character's quest states,
enriching each quest with objective progress information.
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
enriched_quests = []
if character_id:
try:
# Get character's quests with progress
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_quests = quests_data.get('active_quests', [])
# Process each quest to add display-friendly data
for quest in active_quests:
progress_data = quest.get('progress', {})
objectives_progress = progress_data.get('objectives_progress', {})
# Enrich objectives with progress
enriched_objectives = []
all_complete = True
for obj in quest.get('objectives', []):
obj_id = obj.get('objective_id', obj.get('description', ''))
current = objectives_progress.get(obj_id, 0)
# Parse required from progress_text or use default
progress_text = obj.get('progress_text', '0/1')
required = int(progress_text.split('/')[1]) if '/' in progress_text else 1
is_complete = current >= required
if not is_complete:
all_complete = False
enriched_objectives.append({
'description': obj.get('description', ''),
'current': current,
'required': required,
'is_complete': is_complete
})
enriched_quests.append({
'quest_id': quest.get('quest_id', ''),
'name': quest.get('name', 'Unknown Quest'),
'description': quest.get('description', ''),
'difficulty': quest.get('difficulty', 'easy'),
'quest_giver': quest.get('quest_giver_name', ''),
'objectives': enriched_objectives,
'rewards': quest.get('rewards', {}),
'is_complete': all_complete
})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_character_quests", character_id=character_id, error=str(e))
return render_template(
'game/partials/sidebar_quests.html',
session_id=session_id,
quests=enriched_quests
)
except APIError as e:
logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load quests: {e}</div>', 500
@game_bp.route('/session/<session_id>/npcs')
@require_auth
def npcs_accordion(session_id: str):
"""Refresh NPCs accordion content."""
client = get_api_client()
try:
# Get session to find current location
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
current_location_id = game_state.get('current_location_id') or game_state.get('current_location')
# Get NPCs at location
npcs = []
if current_location_id:
try:
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}')
npcs = npcs_response.get('result', {}).get('npcs', [])
except (APINotFoundError, APIError):
pass
return render_template(
'game/partials/sidebar_npcs.html',
session_id=session_id,
npcs=npcs
)
except APIError as e:
logger.error("failed_to_refresh_npcs", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load NPCs: {e}</div>', 500
@game_bp.route('/session/<session_id>/map')
@require_auth
def map_accordion(session_id: str):
"""Refresh map accordion content."""
client = get_api_client()
try:
# Get session for current location
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
current_location = _build_location_from_game_state(game_state)
current_location_id = current_location.get('location_id')
# Get available travel destinations
discovered_locations = []
try:
travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}')
travel_result = travel_response.get('result', {})
discovered_locations = travel_result.get('available_locations', [])
# Mark current location
for loc in discovered_locations:
loc['is_current'] = loc.get('location_id') == current_location_id
except (APINotFoundError, APIError):
pass
return render_template(
'game/partials/sidebar_map.html',
session_id=session_id,
discovered_locations=discovered_locations,
current_location=current_location
)
except APIError as e:
logger.error("failed_to_refresh_map", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load map: {e}</div>', 500
# ===== Action Routes =====
@game_bp.route('/session/<session_id>/action', methods=['POST'])
@require_auth
def take_action(session_id: str):
"""
Submit an action - returns job polling partial.
Handles two action types:
- 'button': Predefined action via prompt_id
- 'custom': Free-form player text input
"""
client = get_api_client()
action_type = request.form.get('action_type', 'button')
try:
# Build payload based on action type
payload = {'action_type': action_type}
if action_type == 'text' or action_type == 'custom':
# Free-form text action from player input
action_text = request.form.get('action_text', request.form.get('custom_text', '')).strip()
if not action_text:
return '<div class="dm-response error">Please enter an action.</div>', 400
logger.info("Player text action submitted",
session_id=session_id,
action_text=action_text[:100])
payload['action_type'] = 'custom'
payload['custom_text'] = action_text
player_action = action_text
else:
# Button action via prompt_id
prompt_id = request.form.get('prompt_id')
if not prompt_id:
return '<div class="dm-response error">No action selected.</div>', 400
logger.info("Player button action submitted",
session_id=session_id,
prompt_id=prompt_id)
payload['prompt_id'] = prompt_id
player_action = None # Will display prompt_id display text
# POST to API
response = client.post(f'/api/v1/sessions/{session_id}/action', payload)
result = response.get('result', {})
job_id = result.get('job_id')
if not job_id:
# Immediate response (shouldn't happen, but handle it)
dm_response = result.get('dm_response', 'Action completed.')
return render_template(
'game/partials/dm_response.html',
session_id=session_id,
dm_response=dm_response
)
# Return polling partial
return render_template(
'game/partials/job_polling.html',
session_id=session_id,
job_id=job_id,
status=result.get('status', 'queued'),
player_action=player_action
)
except APIError as e:
logger.error("failed_to_take_action", session_id=session_id, error=str(e))
return f'<div class="dm-response error">Action failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/job/<job_id>')
@require_auth
def poll_job(session_id: str, job_id: str):
"""Poll job status - returns updated partial."""
client = get_api_client()
# Get hx_target and hx_swap from query params (passed through from original request)
hx_target = request.args.get('_hx_target')
hx_swap = request.args.get('_hx_swap')
try:
response = client.get(f'/api/v1/jobs/{job_id}/status')
result = response.get('result', {})
status = result.get('status', 'unknown')
if status == 'completed':
# Job done - check for NPC dialogue vs story action
nested_result = result.get('result', {})
if nested_result.get('context_type') == 'npc_dialogue':
# NPC dialogue response - return dialogue partial
# Include quest_offer data for inline quest offer card
return render_template(
'game/partials/npc_dialogue_response.html',
npc_name=nested_result.get('npc_name', 'NPC'),
character_name=nested_result.get('character_name', 'You'),
conversation_history=nested_result.get('conversation_history', []),
player_line=nested_result.get('player_line', ''),
dialogue=nested_result.get('dialogue', 'No response'),
quest_offer=nested_result.get('quest_offer'), # Quest offer data for UI card
npc_id=nested_result.get('npc_id'), # NPC ID for accept/decline
session_id=session_id
)
else:
# Standard DM response
dm_response = result.get('dm_response', nested_result.get('dm_response', 'No response'))
return render_template(
'game/partials/dm_response.html',
session_id=session_id,
dm_response=dm_response
)
elif status in ('failed', 'error'):
error_msg = result.get('error', 'Unknown error occurred')
return f'<div class="dm-response error">Action failed: {error_msg}</div>'
else:
# Still processing - return polling partial to continue
# Pass through hx_target and hx_swap to maintain targeting
return render_template(
'game/partials/job_polling.html',
session_id=session_id,
job_id=job_id,
status=status,
hx_target=hx_target,
hx_swap=hx_swap
)
except APIError as e:
logger.error("failed_to_poll_job", job_id=job_id, session_id=session_id, error=str(e))
return f'<div class="dm-response error">Failed to check job status: {e}</div>', 500
# ===== Modal Routes =====
@game_bp.route('/session/<session_id>/equipment-modal')
@require_auth
def equipment_modal(session_id: str):
"""Get equipment modal with character's gear."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
character = {}
if character_id:
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
character = _build_character_from_api(char_data)
return render_template(
'game/partials/equipment_modal.html',
session_id=session_id,
character=character
)
except APIError as e:
logger.error("failed_to_load_equipment_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Equipment</h3>
<div class="error">Failed to load equipment: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/travel-modal')
@require_auth
def travel_modal(session_id: str):
"""Get travel modal with available destinations."""
client = get_api_client()
try:
# Get available travel destinations
response = client.get(f'/api/v1/travel/available?session_id={session_id}')
result = response.get('result', {})
available_locations = result.get('available_locations', [])
current_location_id = result.get('current_location')
# Get current location details from session
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
current_location = _build_location_from_game_state(game_state)
# Filter out current location from destinations
destinations = [loc for loc in available_locations if loc.get('location_id') != current_location_id]
return render_template(
'game/partials/travel_modal.html',
session_id=session_id,
destinations=destinations,
current_location=current_location
)
except APIError as e:
logger.error("failed_to_load_travel_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Travel</h3>
<div class="error">Failed to load travel options: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/travel', methods=['POST'])
@require_auth
def do_travel(session_id: str):
"""Execute travel to location - returns job polling partial or immediate response."""
client = get_api_client()
location_id = request.form.get('location_id')
if not location_id:
return '<div class="error">No destination selected.</div>', 400
try:
response = client.post('/api/v1/travel', {
'session_id': session_id,
'location_id': location_id
})
result = response.get('result', {})
# Check if travel triggers a job (narrative generation)
job_id = result.get('job_id')
if job_id:
# Close modal and return job polling partial
return f'''
<script>document.querySelector('.modal-overlay')?.remove();</script>
''' + render_template(
'game/partials/job_polling.html',
job_id=job_id,
session_id=session_id,
status='queued'
)
# Immediate response (no AI generation)
narrative = result.get('narrative', result.get('description', 'You arrive at your destination.'))
location_name = result.get('location_name', 'Unknown Location')
# Close modal and update response area
return f'''
<script>document.querySelector('.modal-overlay')?.remove();</script>
<div class="dm-response">
<strong>Arrived at {location_name}</strong><br><br>
{narrative}
</div>
''' + render_template(
'game/partials/dm_response.html',
session_id=session_id,
dm_response=f"**Arrived at {location_name}**\n\n{narrative}"
)
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
@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):
"""
Dedicated NPC chat page (mobile-friendly full page view).
Used on mobile devices for better UX.
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Get NPC details with relationship info
npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}')
npc_data = npc_response.get('result', {})
npc = {
'npc_id': npc_data.get('npc_id'),
'name': npc_data.get('name'),
'role': npc_data.get('role'),
'appearance': npc_data.get('appearance', {}).get('brief', ''),
'tags': npc_data.get('tags', []),
'image_url': npc_data.get('image_url')
}
# Get relationship info
interaction_summary = npc_data.get('interaction_summary', {})
relationship_level = interaction_summary.get('relationship_level', 50)
interaction_count = interaction_summary.get('interaction_count', 0)
# Conversation history would come from character's npc_interactions
# For now, we'll leave it empty - the API returns it in dialogue responses
conversation_history = []
return render_template(
'game/npc_chat_page.html',
session_id=session_id,
npc=npc,
conversation_history=conversation_history,
relationship_level=relationship_level,
interaction_count=interaction_count
)
except APINotFoundError:
return render_template('errors/404.html', message="NPC not found"), 404
except APIError as e:
logger.error("failed_to_load_npc_chat_page", session_id=session_id, npc_id=npc_id, error=str(e))
return render_template('errors/500.html', message=f"Failed to load NPC: {e}"), 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/chat')
@require_auth
def npc_chat_modal(session_id: str, npc_id: str):
"""
Get NPC chat modal with conversation history.
Used on desktop for modal overlay experience.
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Get NPC details with relationship info
npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}')
npc_data = npc_response.get('result', {})
npc = {
'npc_id': npc_data.get('npc_id'),
'name': npc_data.get('name'),
'role': npc_data.get('role'),
'appearance': npc_data.get('appearance', {}).get('brief', ''),
'tags': npc_data.get('tags', []),
'image_url': npc_data.get('image_url')
}
# Get relationship info
interaction_summary = npc_data.get('interaction_summary', {})
relationship_level = interaction_summary.get('relationship_level', 50)
interaction_count = interaction_summary.get('interaction_count', 0)
# Conversation history would come from character's npc_interactions
# For now, we'll leave it empty - the API returns it in dialogue responses
conversation_history = []
return render_template(
'game/partials/npc_chat_modal.html',
session_id=session_id,
npc=npc,
conversation_history=conversation_history,
relationship_level=relationship_level,
interaction_count=interaction_count
)
except APINotFoundError:
return '<div class="error">NPC not found</div>', 404
except APIError as e:
logger.error("failed_to_load_npc_chat", session_id=session_id, npc_id=npc_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Talk to NPC</h3>
<div class="error">Failed to load NPC info: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/npc/<npc_id>/history')
@require_auth
def npc_chat_history(session_id: str, npc_id: str):
"""Get last 5 chat messages for history sidebar."""
client = get_api_client()
try:
# Get session to find character_id
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Fetch last 5 messages from chat service
# API endpoint: GET /api/v1/characters/{character_id}/chats/{npc_id}?limit=5
history_response = client.get(
f'/api/v1/characters/{character_id}/chats/{npc_id}',
params={'limit': 5, 'offset': 0}
)
result_data = history_response.get('result', {})
messages = result_data.get('messages', []) # Extract messages array from result
# Render history partial
return render_template(
'game/partials/npc_chat_history.html',
messages=messages,
session_id=session_id,
npc_id=npc_id
)
except APIError as e:
logger.error("failed_to_load_chat_history", session_id=session_id, npc_id=npc_id, error=str(e))
return '<div class="history-empty">Failed to load history</div>', 500
# ===== Inventory Routes =====
@game_bp.route('/session/<session_id>/inventory-modal')
@require_auth
def inventory_modal(session_id: str):
"""
Get inventory modal with all items.
Supports filtering by item type via ?filter= parameter.
"""
client = get_api_client()
filter_type = request.args.get('filter', 'all')
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
inventory = []
equipped = {}
gold = 0
inventory_count = 0
inventory_max = 100
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', [])
equipped = inv_data.get('equipped', {})
inventory_count = inv_data.get('inventory_count', len(inventory))
inventory_max = inv_data.get('max_inventory', 100)
# Get gold from character
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
gold = char_data.get('gold', 0)
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_inventory", character_id=character_id, error=str(e))
# Filter inventory by type if specified
if filter_type != 'all':
inventory = [item for item in inventory if item.get('item_type') == filter_type]
return render_template(
'game/partials/inventory_modal.html',
session_id=session_id,
inventory=inventory,
equipped=equipped,
gold=gold,
inventory_count=inventory_count,
inventory_max=inventory_max,
filter=filter_type
)
except APIError as e:
logger.error("failed_to_load_inventory_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content inventory-modal">
<div class="modal-header">
<h2>Inventory</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="inventory-empty">Failed to load inventory: {e}</div>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/inventory/item/<item_id>')
@require_auth
def inventory_item_detail(session_id: str, item_id: str):
"""Get item detail partial for HTMX swap."""
client = get_api_client()
try:
# Get session to find character
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:
# Get inventory and find the item
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 '<div class="item-detail-empty">Item not found</div>', 404
# Determine suggested slot for equipment
suggested_slot = None
item_type = item.get('item_type', '')
if item_type == 'weapon':
suggested_slot = 'weapon'
elif item_type == 'armor':
# Could be any armor slot - default to chest
suggested_slot = 'chest'
return render_template(
'game/partials/inventory_item_detail.html',
session_id=session_id,
item=item,
suggested_slot=suggested_slot
)
except APIError as e:
logger.error("failed_to_load_item_detail", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="item-detail-empty">Failed to load item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/use', methods=['POST'])
@require_auth
def inventory_use(session_id: str):
"""Use a consumable item."""
client = get_api_client()
item_id = request.form.get('item_id')
if not item_id:
return '<div class="error">No item selected</div>', 400
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return '<div class="error">No character found</div>', 400
# Use the item via API
client.post(f'/api/v1/characters/{character_id}/inventory/use', {
'item_id': item_id
})
# Return updated character panel
return redirect(url_for('game.character_panel', session_id=session_id))
except APIError as e:
logger.error("failed_to_use_item", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="error">Failed to use item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/equip', methods=['POST'])
@require_auth
def inventory_equip(session_id: str):
"""Equip an item to a slot."""
client = get_api_client()
item_id = request.form.get('item_id')
slot = request.form.get('slot')
if not item_id:
return '<div class="error">No item selected</div>', 400
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return '<div class="error">No character found</div>', 400
# Equip the item via API
payload = {'item_id': item_id}
if slot:
payload['slot'] = slot
client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload)
# Return updated character panel
return redirect(url_for('game.character_panel', session_id=session_id))
except APIError as e:
logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="error">Failed to equip item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/<item_id>', methods=['DELETE'])
@require_auth
def inventory_drop(session_id: str, item_id: str):
"""Drop (delete) an item from inventory."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return '<div class="error">No character found</div>', 400
# Delete the item via API
client.delete(f'/api/v1/characters/{character_id}/inventory/{item_id}')
# Return updated inventory modal
return redirect(url_for('game.inventory_modal', session_id=session_id))
except APIError as e:
logger.error("failed_to_drop_item", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="error">Failed to drop item: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
@require_auth
def talk_to_npc(session_id: str, npc_id: str):
"""Send message to NPC - returns dialogue response or job polling partial."""
client = get_api_client()
# Support both topic (initial greeting) and player_response (conversation)
player_response = request.form.get('player_response')
topic = request.form.get('topic', 'greeting')
try:
payload = {'session_id': session_id}
if player_response:
# Player typed a custom response
payload['player_response'] = player_response
else:
# Initial greeting click
payload['topic'] = topic
response = client.post(f'/api/v1/npcs/{npc_id}/talk', payload)
result = response.get('result', {})
# Check if it's a job-based response (async) or immediate
job_id = result.get('job_id')
if job_id:
# Return job polling partial for the chat area
# Use hx-target="this" and hx-swap="outerHTML" to replace loading div with response in-place
return render_template(
'game/partials/job_polling.html',
job_id=job_id,
session_id=session_id,
status='queued',
is_npc_dialogue=True,
hx_target='this', # Target the loading div itself
hx_swap='outerHTML' # Replace entire loading div with response
)
# Immediate response (if AI is sync or cached)
dialogue = result.get('dialogue', result.get('response', 'The NPC has nothing to say.'))
npc_name = result.get('npc_name', 'NPC')
# Return dialogue in chat format
player_display = player_response if player_response else f"[{topic}]"
return f'''
<div class="chat-message chat-message--player">
<strong>You:</strong> {player_display}
</div>
<div class="chat-message chat-message--npc">
<strong>{npc_name}:</strong> {dialogue}
</div>
'''
except APINotFoundError:
return '<div class="error">NPC not found.</div>', 404
except APIError as e:
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500
# ===== Quest Accept/Decline Routes =====
@game_bp.route('/session/<session_id>/quest/accept', methods=['POST'])
@require_auth
def accept_quest(session_id: str):
"""
Accept a quest offer from NPC chat.
Called when player clicks 'Accept Quest' button on inline quest offer card.
Returns updated card with confirmation message and triggers toast notification.
"""
client = get_api_client()
quest_id = request.form.get('quest_id')
npc_id = request.form.get('npc_id')
npc_name = request.form.get('npc_name', 'NPC')
if not quest_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='No quest specified',
session_id=session_id
)
try:
# Get character_id from session
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='Session error - no character found',
session_id=session_id
)
# Call API to accept quest
response = client.post('/api/v1/quests/accept', {
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
result = response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
logger.info(
"quest_accepted",
quest_id=quest_id,
quest_name=quest_name,
character_id=character_id,
session_id=session_id
)
return render_template(
'game/partials/quest_action_response.html',
action='accept',
quest_name=quest_name,
npc_name=npc_name,
session_id=session_id
)
except APIError as e:
logger.error(
"failed_to_accept_quest",
quest_id=quest_id,
session_id=session_id,
error=str(e)
)
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message=str(e),
session_id=session_id
)
@game_bp.route('/session/<session_id>/quest/decline', methods=['POST'])
@require_auth
def decline_quest(session_id: str):
"""
Decline a quest offer from NPC chat.
Called when player clicks 'Decline' button on inline quest offer card.
Returns updated card with decline confirmation.
"""
client = get_api_client()
quest_id = request.form.get('quest_id')
npc_id = request.form.get('npc_id')
npc_name = request.form.get('npc_name', 'NPC')
if not quest_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='No quest specified',
session_id=session_id
)
try:
# Get character_id from session
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='Session error - no character found',
session_id=session_id
)
# Call API to decline quest
client.post('/api/v1/quests/decline', {
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
logger.info(
"quest_declined",
quest_id=quest_id,
character_id=character_id,
session_id=session_id
)
return render_template(
'game/partials/quest_action_response.html',
action='decline',
quest_name='', # Not needed for decline message
npc_name=npc_name,
session_id=session_id
)
except APIError as e:
logger.error(
"failed_to_decline_quest",
quest_id=quest_id,
session_id=session_id,
error=str(e)
)
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message=str(e),
session_id=session_id
)
# ===== Shop Routes =====
@game_bp.route('/session/<session_id>/shop-modal')
@require_auth
def shop_modal(session_id: str):
"""
Get shop modal for browsing and purchasing items.
Supports filtering by item type via ?filter= parameter.
Uses the general_store shop.
"""
client = get_api_client()
filter_type = request.args.get('filter', 'all')
message = request.args.get('message', '')
error = request.args.get('error', '')
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
gold = 0
inventory = []
shop = {}
if character_id:
try:
# Get shop inventory with character context (for affordability)
shop_response = client.get(
f'/api/v1/shop/general_store/inventory',
params={'character_id': character_id}
)
shop_data = shop_response.get('result', {})
shop = shop_data.get('shop', {})
inventory = shop_data.get('inventory', [])
# Get character gold
char_data = shop_data.get('character', {})
gold = char_data.get('gold', 0)
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_shop", character_id=character_id, error=str(e))
error = "Failed to load shop inventory"
# Filter inventory by type if specified
if filter_type != 'all':
inventory = [
entry for entry in inventory
if entry.get('item', {}).get('item_type') == filter_type
]
return render_template(
'game/partials/shop_modal.html',
session_id=session_id,
shop=shop,
inventory=inventory,
gold=gold,
filter=filter_type,
message=message,
error=error
)
except APIError as e:
logger.error("failed_to_load_shop_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content shop-modal">
<div class="modal-header">
<h2>Shop</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="shop-empty">Failed to load shop: {e}</div>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/shop/purchase', methods=['POST'])
@require_auth
def shop_purchase(session_id: str):
"""
Purchase an item from the shop.
HTMX endpoint - returns updated shop modal.
"""
client = get_api_client()
item_id = request.form.get('item_id')
quantity = int(request.form.get('quantity', 1))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return shop_modal_with_error(session_id, "No character found for this session")
if not item_id:
return shop_modal_with_error(session_id, "No item specified")
# Attempt purchase
purchase_data = {
'character_id': character_id,
'item_id': item_id,
'quantity': quantity,
'session_id': session_id
}
response = client.post('/api/v1/shop/general_store/purchase', json=purchase_data)
result = response.get('result', {})
# Get item name for message
purchase_info = result.get('purchase', {})
item_name = purchase_info.get('item_id', item_id)
total_cost = purchase_info.get('total_cost', 0)
message = f"Purchased {item_name} for {total_cost} gold!"
logger.info(
"shop_purchase_success",
session_id=session_id,
character_id=character_id,
item_id=item_id,
quantity=quantity,
total_cost=total_cost
)
# Re-render shop modal with success message
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
except APIError as e:
logger.error(
"shop_purchase_failed",
session_id=session_id,
item_id=item_id,
error=str(e)
)
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
@game_bp.route('/session/<session_id>/shop/sell', methods=['POST'])
@require_auth
def shop_sell(session_id: str):
"""
Sell an item to the shop.
HTMX endpoint - returns updated shop modal.
"""
client = get_api_client()
item_instance_id = request.form.get('item_instance_id')
quantity = int(request.form.get('quantity', 1))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return redirect(url_for('game.shop_modal', session_id=session_id, error="No character found"))
if not item_instance_id:
return redirect(url_for('game.shop_modal', session_id=session_id, error="No item specified"))
# Attempt sale
sale_data = {
'character_id': character_id,
'item_instance_id': item_instance_id,
'quantity': quantity,
'session_id': session_id
}
response = client.post('/api/v1/shop/general_store/sell', json=sale_data)
result = response.get('result', {})
sale_info = result.get('sale', {})
item_name = sale_info.get('item_name', 'Item')
total_earned = sale_info.get('total_earned', 0)
message = f"Sold {item_name} for {total_earned} gold!"
logger.info(
"shop_sell_success",
session_id=session_id,
character_id=character_id,
item_instance_id=item_instance_id,
total_earned=total_earned
)
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
except APIError as e:
logger.error(
"shop_sell_failed",
session_id=session_id,
item_instance_id=item_instance_id,
error=str(e)
)
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
def shop_modal_with_error(session_id: str, error: str):
"""Helper to render shop modal with an error message."""
return redirect(url_for('game.shop_modal', session_id=session_id, error=error))
# ===== Quest Routes =====
@game_bp.route('/session/<session_id>/quest/<quest_id>')
@require_auth
def quest_detail(session_id: str, quest_id: str):
"""
Get quest detail modal showing progress and options.
Displays the full quest details with objective progress,
rewards, and options to abandon (if in progress) or
claim rewards (if complete).
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Get quest details
quest_response = client.get(f'/api/v1/quests/{quest_id}')
quest = quest_response.get('result', {})
if not quest:
return _quest_error_modal(f"Quest not found: {quest_id}")
# Get character's quest progress
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_quests = quests_data.get('active_quests', [])
# Find this quest's progress
quest_state = None
objectives_progress = {}
accepted_at = None
for active_quest in active_quests:
if active_quest.get('quest_id') == quest_id:
progress_data = active_quest.get('progress', {})
objectives_progress = progress_data.get('objectives_progress', {})
accepted_at = progress_data.get('accepted_at', '')
break
# Build enriched objectives with progress
enriched_objectives = []
all_complete = True
for obj in quest.get('objectives', []):
obj_id = obj.get('objective_id', '')
progress_text = obj.get('progress_text', '0/1')
required = int(progress_text.split('/')[1]) if '/' in progress_text else 1
current = objectives_progress.get(obj_id, 0)
is_complete = current >= required
if not is_complete:
all_complete = False
enriched_objectives.append({
'objective_id': obj_id,
'description': obj.get('description', ''),
'current_progress': current,
'required_progress': required,
'is_complete': is_complete
})
return render_template(
'game/partials/quest_detail_modal.html',
session_id=session_id,
quest=quest,
objectives=enriched_objectives,
quest_complete=all_complete,
accepted_at=accepted_at
)
except APIError as e:
logger.error("failed_to_load_quest_detail", session_id=session_id, quest_id=quest_id, error=str(e))
return _quest_error_modal(f"Failed to load quest: {e}")
@game_bp.route('/session/<session_id>/quest/offer/<quest_id>')
@require_auth
def quest_offer(session_id: str, quest_id: str):
"""
Display quest offer modal.
Shows quest details when an NPC offers a quest,
with accept/decline options.
"""
client = get_api_client()
npc_id = request.args.get('npc_id', '')
npc_name = request.args.get('npc_name', '')
offer_dialogue = request.args.get('offer_dialogue', '')
try:
# Get session to check character's quest count
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
at_max_quests = False
if character_id:
try:
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_count = quests_data.get('active_count', 0)
at_max_quests = active_count >= 2
except APIError:
pass
# Get quest details
quest_response = client.get(f'/api/v1/quests/{quest_id}')
quest = quest_response.get('result', {})
if not quest:
return _quest_error_modal(f"Quest not found: {quest_id}")
return render_template(
'game/partials/quest_offer_modal.html',
session_id=session_id,
quest=quest,
npc_id=npc_id,
npc_name=npc_name,
offer_dialogue=offer_dialogue,
at_max_quests=at_max_quests
)
except APIError as e:
logger.error("failed_to_load_quest_offer", session_id=session_id, quest_id=quest_id, error=str(e))
return _quest_error_modal(f"Failed to load quest offer: {e}")
@game_bp.route('/session/<session_id>/quest/accept', methods=['POST'])
@require_auth
def quest_accept(session_id: str):
"""
Accept a quest offer.
Calls the API to add the quest to the character's active quests.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id')
npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Accept the quest
accept_response = client.post('/api/v1/quests/accept', json={
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
result = accept_response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
logger.info(
"quest_accepted",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Return success message that will close modal
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-success-modal">
<div class="modal-header">
<h2>Quest Accepted!</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-success-text">You have accepted: <strong>{quest_name}</strong></p>
<p class="quest-success-hint">Check your Quest Log to track your progress.</p>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">Continue</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_accept_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to accept quest: {error_msg}")
@game_bp.route('/session/<session_id>/quest/decline', methods=['POST'])
@require_auth
def quest_decline(session_id: str):
"""
Decline a quest offer.
Sets a flag to prevent immediate re-offering.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Decline the quest
client.post('/api/v1/quests/decline', json={
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
logger.info(
"quest_declined",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Just close the modal
return ''
except APIError as e:
logger.error("quest_decline_failed", session_id=session_id, quest_id=quest_id, error=str(e))
return '' # Close modal anyway
@game_bp.route('/session/<session_id>/quest/abandon', methods=['POST'])
@require_auth
def quest_abandon(session_id: str):
"""
Abandon an active quest.
Removes the quest from active quests.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Abandon the quest
client.post('/api/v1/quests/abandon', json={
'character_id': character_id,
'quest_id': quest_id
})
logger.info(
"quest_abandoned",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Return confirmation that will close modal
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-abandon-modal">
<div class="modal-header">
<h2>Quest Abandoned</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p>You have abandoned the quest. You can accept it again later if offered.</p>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">OK</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_abandon_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to abandon quest: {error_msg}")
@game_bp.route('/session/<session_id>/quest/complete', methods=['POST'])
@require_auth
def quest_complete(session_id: str):
"""
Complete a quest and claim rewards.
Grants rewards and moves quest to completed list.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Complete the quest
complete_response = client.post('/api/v1/quests/complete', json={
'character_id': character_id,
'quest_id': quest_id
})
result = complete_response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
rewards = result.get('rewards', {})
leveled_up = result.get('leveled_up', False)
new_level = result.get('new_level')
logger.info(
"quest_completed",
session_id=session_id,
character_id=character_id,
quest_id=quest_id,
rewards=rewards
)
# Build rewards display
rewards_html = []
if rewards.get('gold'):
rewards_html.append(f"<li>&#128176; {rewards['gold']} Gold</li>")
if rewards.get('experience'):
rewards_html.append(f"<li>&#9733; {rewards['experience']} XP</li>")
for item in rewards.get('items', []):
rewards_html.append(f"<li>&#127873; {item}</li>")
level_up_html = ""
if leveled_up and new_level:
level_up_html = f'<div class="quest-level-up">Level Up! You are now level {new_level}!</div>'
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-complete-modal">
<div class="modal-header">
<h2>Quest Complete!</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-complete-title">Completed: <strong>{quest_name}</strong></p>
{level_up_html}
<div class="quest-rewards-received">
<h4>Rewards:</h4>
<ul>{"".join(rewards_html)}</ul>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">Excellent!</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_complete_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to complete quest: {error_msg}")
def _quest_error_modal(error_message: str) -> str:
"""Helper to render a quest error modal."""
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-error-modal">
<div class="modal-header">
<h2>Error</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-error-text">{error_message}</p>
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
'''