383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""
|
|
Development-only views for testing API functionality.
|
|
|
|
This blueprint only loads when FLASK_ENV=development.
|
|
Provides HTMX-based testing interfaces for API endpoints.
|
|
"""
|
|
|
|
from flask import Blueprint, render_template, request, jsonify
|
|
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__)
|
|
|
|
dev_bp = Blueprint('dev', __name__, url_prefix='/dev')
|
|
|
|
|
|
@dev_bp.route('/')
|
|
def index():
|
|
"""Dev tools hub - links to all testing interfaces."""
|
|
return render_template('dev/index.html')
|
|
|
|
|
|
@dev_bp.route('/story')
|
|
@require_auth
|
|
def story_hub():
|
|
"""Story testing hub - select character and create/load sessions."""
|
|
client = get_api_client()
|
|
|
|
try:
|
|
# Get user's characters
|
|
characters_response = client.get('/api/v1/characters')
|
|
result = characters_response.get('result', {})
|
|
characters = result.get('characters', [])
|
|
|
|
# Get user's active sessions (if endpoint exists)
|
|
sessions = []
|
|
try:
|
|
sessions_response = client.get('/api/v1/sessions')
|
|
sessions = sessions_response.get('result', [])
|
|
except (APINotFoundError, APIError):
|
|
# Sessions list endpoint may not exist yet or has issues
|
|
pass
|
|
|
|
return render_template(
|
|
'dev/story.html',
|
|
characters=characters,
|
|
sessions=sessions
|
|
)
|
|
except APIError as e:
|
|
logger.error("failed_to_load_story_hub", error=str(e))
|
|
return render_template('dev/story.html', characters=[], sessions=[], error=str(e))
|
|
|
|
|
|
@dev_bp.route('/story/session/<session_id>')
|
|
@require_auth
|
|
def story_session(session_id: str):
|
|
"""Story session gameplay interface."""
|
|
client = get_api_client()
|
|
|
|
try:
|
|
# Get session state
|
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
|
session_data = session_response.get('result', {})
|
|
|
|
# Get session history
|
|
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=50')
|
|
history_data = history_response.get('result', {})
|
|
|
|
# Get NPCs at current location
|
|
npcs_present = []
|
|
game_state = session_data.get('game_state', {})
|
|
current_location = game_state.get('current_location_id') or game_state.get('current_location')
|
|
if current_location:
|
|
try:
|
|
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location}')
|
|
npcs_present = npcs_response.get('result', {}).get('npcs', [])
|
|
except (APINotFoundError, APIError):
|
|
# NPCs endpoint may not exist yet
|
|
pass
|
|
|
|
return render_template(
|
|
'dev/story_session.html',
|
|
session=session_data,
|
|
history=history_data.get('history', []),
|
|
session_id=session_id,
|
|
npcs_present=npcs_present
|
|
)
|
|
except APINotFoundError:
|
|
return render_template('dev/story.html', error=f"Session {session_id} not found"), 404
|
|
except APIError as e:
|
|
logger.error("failed_to_load_session", session_id=session_id, error=str(e))
|
|
return render_template('dev/story.html', error=str(e)), 500
|
|
|
|
|
|
# HTMX Partial endpoints
|
|
|
|
@dev_bp.route('/story/create-session', methods=['POST'])
|
|
@require_auth
|
|
def create_session():
|
|
"""Create a new story session - returns HTMX partial."""
|
|
client = get_api_client()
|
|
character_id = request.form.get('character_id')
|
|
|
|
logger.info("create_session called",
|
|
character_id=character_id,
|
|
form_data=dict(request.form))
|
|
|
|
if not character_id:
|
|
return '<div class="error">No character selected</div>', 400
|
|
|
|
try:
|
|
response = client.post('/api/v1/sessions', {'character_id': character_id})
|
|
session_data = response.get('result', {})
|
|
session_id = session_data.get('session_id')
|
|
|
|
# Return redirect script to session page
|
|
return f'''
|
|
<script>window.location.href = '/dev/story/session/{session_id}';</script>
|
|
<div class="success">Session created! Redirecting...</div>
|
|
'''
|
|
except APIError as e:
|
|
logger.error("failed_to_create_session", character_id=character_id, error=str(e))
|
|
return f'<div class="error">Failed to create session: {e}</div>', 500
|
|
|
|
|
|
@dev_bp.route('/story/action/<session_id>', methods=['POST'])
|
|
@require_auth
|
|
def take_action(session_id: str):
|
|
"""Submit an action - returns job status partial for polling."""
|
|
client = get_api_client()
|
|
|
|
action_type = request.form.get('action_type', 'button')
|
|
prompt_id = request.form.get('prompt_id')
|
|
custom_text = request.form.get('custom_text')
|
|
question = request.form.get('question')
|
|
|
|
payload = {'action_type': action_type}
|
|
if action_type == 'button' and prompt_id:
|
|
payload['prompt_id'] = prompt_id
|
|
elif action_type == 'custom' and custom_text:
|
|
payload['custom_text'] = custom_text
|
|
elif action_type == 'ask_dm' and question:
|
|
payload['question'] = question
|
|
|
|
try:
|
|
response = client.post(f'/api/v1/sessions/{session_id}/action', payload)
|
|
result = response.get('result', {})
|
|
job_id = result.get('job_id')
|
|
|
|
# Return polling partial
|
|
return render_template(
|
|
'dev/partials/job_status.html',
|
|
job_id=job_id,
|
|
session_id=session_id,
|
|
status='queued'
|
|
)
|
|
except APIError as e:
|
|
logger.error("failed_to_take_action", session_id=session_id, error=str(e))
|
|
return f'<div class="error">Action failed: {e}</div>', 500
|
|
|
|
|
|
@dev_bp.route('/story/job-status/<job_id>')
|
|
@require_auth
|
|
def job_status(job_id: str):
|
|
"""Poll job status - returns updated partial."""
|
|
client = get_api_client()
|
|
session_id = request.args.get('session_id', '')
|
|
|
|
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 - return response
|
|
# Check for NPC dialogue (in result.dialogue) vs story action (in dm_response)
|
|
nested_result = result.get('result', {})
|
|
if nested_result.get('context_type') == 'npc_dialogue':
|
|
# Use NPC dialogue template with conversation history
|
|
return render_template(
|
|
'dev/partials/npc_dialogue.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:
|
|
dm_response = result.get('dm_response', 'No response')
|
|
|
|
return render_template(
|
|
'dev/partials/dm_response.html',
|
|
dm_response=dm_response,
|
|
raw_result=result,
|
|
session_id=session_id
|
|
)
|
|
elif status in ('failed', 'error'):
|
|
error_msg = result.get('error', 'Unknown error')
|
|
return f'<div class="error">Job failed: {error_msg}</div>'
|
|
else:
|
|
# Still processing - return polling partial
|
|
return render_template(
|
|
'dev/partials/job_status.html',
|
|
job_id=job_id,
|
|
session_id=session_id,
|
|
status=status
|
|
)
|
|
except APIError as e:
|
|
logger.error("failed_to_get_job_status", job_id=job_id, error=str(e))
|
|
return f'<div class="error">Failed to get job status: {e}</div>', 500
|
|
|
|
|
|
@dev_bp.route('/story/history/<session_id>')
|
|
@require_auth
|
|
def get_history(session_id: str):
|
|
"""Get session history - returns HTMX partial."""
|
|
client = get_api_client()
|
|
limit = request.args.get('limit', 20, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
|
|
try:
|
|
response = client.get(f'/api/v1/sessions/{session_id}/history?limit={limit}&offset={offset}')
|
|
result = response.get('result', {})
|
|
|
|
return render_template(
|
|
'dev/partials/history.html',
|
|
history=result.get('history', []),
|
|
pagination=result.get('pagination', {}),
|
|
session_id=session_id
|
|
)
|
|
except APIError as e:
|
|
logger.error("failed_to_get_history", session_id=session_id, error=str(e))
|
|
return f'<div class="error">Failed to load history: {e}</div>', 500
|
|
|
|
|
|
@dev_bp.route('/story/state/<session_id>')
|
|
@require_auth
|
|
def get_state(session_id: str):
|
|
"""Get current session state - returns HTMX partial."""
|
|
client = get_api_client()
|
|
|
|
try:
|
|
response = client.get(f'/api/v1/sessions/{session_id}')
|
|
session_data = response.get('result', {})
|
|
|
|
return render_template(
|
|
'dev/partials/session_state.html',
|
|
session=session_data,
|
|
session_id=session_id
|
|
)
|
|
except APIError as e:
|
|
logger.error("failed_to_get_state", session_id=session_id, error=str(e))
|
|
return f'<div class="error">Failed to load state: {e}</div>', 500
|
|
|
|
|
|
# ===== NPC & Travel Endpoints =====
|
|
|
|
@dev_bp.route('/story/talk/<session_id>/<npc_id>', methods=['POST'])
|
|
@require_auth
|
|
def talk_to_npc(session_id: str, npc_id: str):
|
|
"""Talk to an NPC - returns dialogue response."""
|
|
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 render_template(
|
|
'dev/partials/job_status.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 f'''
|
|
<div class="npc-dialogue">
|
|
<div class="npc-dialogue-header">{npc_name} says:</div>
|
|
<div class="npc-dialogue-content">{dialogue}</div>
|
|
</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
|
|
|
|
|
|
@dev_bp.route('/story/travel-modal/<session_id>')
|
|
@require_auth
|
|
def travel_modal(session_id: str):
|
|
"""Get travel modal with available locations."""
|
|
client = get_api_client()
|
|
|
|
try:
|
|
response = client.get(f'/api/v1/travel/available?session_id={session_id}')
|
|
result = response.get('result', {})
|
|
available_locations = result.get('available_locations', [])
|
|
|
|
return render_template(
|
|
'dev/partials/travel_modal.html',
|
|
locations=available_locations,
|
|
session_id=session_id
|
|
)
|
|
except APIError as e:
|
|
logger.error("failed_to_get_travel_options", 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>
|
|
'''
|
|
|
|
|
|
@dev_bp.route('/story/travel/<session_id>', methods=['POST'])
|
|
@require_auth
|
|
def do_travel(session_id: str):
|
|
"""Travel to a new location - returns updated DM 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:
|
|
return render_template(
|
|
'dev/partials/job_status.html',
|
|
job_id=job_id,
|
|
session_id=session_id,
|
|
status='queued'
|
|
)
|
|
|
|
# Immediate response
|
|
narrative = result.get('narrative', result.get('description', 'You arrive at your destination.'))
|
|
location_name = result.get('location_name', 'Unknown Location')
|
|
|
|
# Return script to close modal and update response
|
|
return f'''
|
|
<script>
|
|
document.querySelector('.modal-overlay')?.remove();
|
|
</script>
|
|
<div>
|
|
<strong>Arrived at {location_name}</strong><br><br>
|
|
{narrative}
|
|
</div>
|
|
'''
|
|
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
|