first commit
This commit is contained in:
796
public_web/app/views/game_views.py
Normal file
796
public_web/app/views/game_views.py
Normal file
@@ -0,0 +1,796 @@
|
||||
"""
|
||||
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
|
||||
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': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
|
||||
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
|
||||
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
|
||||
],
|
||||
'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)
|
||||
}
|
||||
|
||||
|
||||
# ===== 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)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
return render_template(
|
||||
'game/partials/character_panel.html',
|
||||
session_id=session_id,
|
||||
character=character,
|
||||
location=location,
|
||||
actions=DEFAULT_ACTIONS,
|
||||
user_tier=user_tier
|
||||
)
|
||||
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."""
|
||||
client = get_api_client()
|
||||
|
||||
try:
|
||||
# Get session to access game_state.active_quests
|
||||
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||
session_data = session_response.get('result', {})
|
||||
game_state = session_data.get('game_state', {})
|
||||
quests = game_state.get('active_quests', [])
|
||||
|
||||
return render_template(
|
||||
'game/partials/sidebar_quests.html',
|
||||
session_id=session_id,
|
||||
quests=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()
|
||||
|
||||
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
|
||||
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'),
|
||||
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
|
||||
return render_template(
|
||||
'game/partials/job_polling.html',
|
||||
session_id=session_id,
|
||||
job_id=job_id,
|
||||
status=status
|
||||
)
|
||||
|
||||
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>/npc/<npc_id>/chat')
|
||||
@require_auth
|
||||
def npc_chat_modal(session_id: str, npc_id: str):
|
||||
"""Get NPC chat modal with conversation history."""
|
||||
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', [])
|
||||
}
|
||||
|
||||
# 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>/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
|
||||
return render_template(
|
||||
'game/partials/job_polling.html',
|
||||
job_id=job_id,
|
||||
session_id=session_id,
|
||||
status='queued',
|
||||
is_npc_dialogue=True
|
||||
)
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user