first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View 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