Issue 1: Slot Name Mismatch - Equipment modal used armor, accessory but API uses chest, accessory_1 - Updated to all 8 API slots: weapon, off_hand, helmet, chest, gloves, boots, accessory_1, accessory_2 Issue 2: HTMX Request Not Firing (the real blocker) - onclick=closeModal() was removing the button from DOM before HTMX could send the request - Changed to hx-on::after-request=closeModal() so modal closes after the request completes
2198 lines
81 KiB
Python
2198 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()">×</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()">×</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
|
|
|
|
if not slot:
|
|
logger.warning("equip_missing_slot", item_id=item_id)
|
|
return '<div class="error">No equipment slot specified</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, '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, slot=slot, 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()">×</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()">×</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()">×</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>💰 {rewards['gold']} Gold</li>")
|
|
if rewards.get('experience'):
|
|
rewards_html.append(f"<li>★ {rewards['experience']} XP</li>")
|
|
for item in rewards.get('items', []):
|
|
rewards_html.append(f"<li>🎁 {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()">×</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()">×</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>
|
|
'''
|