431 lines
14 KiB
Python
431 lines
14 KiB
Python
"""
|
|
NPC API Blueprint
|
|
|
|
This module provides API endpoints for NPC interactions:
|
|
- Get NPC details
|
|
- Talk to NPC (queues AI dialogue generation)
|
|
- Get NPCs at location
|
|
|
|
All endpoints require authentication and enforce ownership validation.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from flask import Blueprint, request
|
|
|
|
from app.services.session_service import get_session_service, SessionNotFound
|
|
from app.services.character_service import get_character_service, CharacterNotFound
|
|
from app.services.npc_loader import get_npc_loader
|
|
from app.services.location_loader import get_location_loader
|
|
from app.tasks.ai_tasks import enqueue_ai_task, TaskType
|
|
from app.utils.response import (
|
|
success_response,
|
|
accepted_response,
|
|
error_response,
|
|
not_found_response,
|
|
validation_error_response
|
|
)
|
|
from app.utils.auth import require_auth, get_current_user
|
|
from app.utils.logging import get_logger
|
|
|
|
|
|
# Initialize logger
|
|
logger = get_logger(__file__)
|
|
|
|
# Create blueprint
|
|
npcs_bp = Blueprint('npcs', __name__)
|
|
|
|
|
|
@npcs_bp.route('/api/v1/npcs/<npc_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_npc_details(npc_id: str):
|
|
"""
|
|
Get NPC details with knowledge filtered by character interaction state.
|
|
|
|
Path params:
|
|
npc_id: NPC ID to get details for
|
|
|
|
Query params:
|
|
character_id: Optional character ID for filtering revealed secrets
|
|
|
|
Returns:
|
|
JSON response with NPC details
|
|
"""
|
|
try:
|
|
user = get_current_user()
|
|
character_id = request.args.get('character_id')
|
|
|
|
# Load NPC
|
|
npc_loader = get_npc_loader()
|
|
npc = npc_loader.load_npc(npc_id)
|
|
|
|
if not npc:
|
|
return not_found_response("NPC not found")
|
|
|
|
npc_data = npc.to_dict()
|
|
|
|
# Filter knowledge based on character interaction state
|
|
if character_id:
|
|
try:
|
|
character_service = get_character_service()
|
|
character = character_service.get_character(character_id, user.id)
|
|
|
|
if character:
|
|
# Get revealed secrets based on conditions
|
|
revealed = character_service.check_npc_secret_conditions(character, npc)
|
|
|
|
# Build available knowledge (public + revealed)
|
|
available_knowledge = []
|
|
if npc.knowledge:
|
|
available_knowledge.extend(npc.knowledge.public)
|
|
available_knowledge.extend(revealed)
|
|
|
|
npc_data["available_knowledge"] = available_knowledge
|
|
|
|
# Remove secret knowledge from response
|
|
if npc_data.get("knowledge"):
|
|
npc_data["knowledge"]["secret"] = []
|
|
npc_data["knowledge"]["will_share_if"] = []
|
|
|
|
# Add interaction summary
|
|
interaction = character.npc_interactions.get(npc_id, {})
|
|
npc_data["interaction_summary"] = {
|
|
"interaction_count": interaction.get("interaction_count", 0),
|
|
"relationship_level": interaction.get("relationship_level", 50),
|
|
"first_met": interaction.get("first_met"),
|
|
}
|
|
|
|
except CharacterNotFound:
|
|
logger.debug("Character not found for NPC filter", character_id=character_id)
|
|
|
|
return success_response(npc_data)
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get NPC details",
|
|
npc_id=npc_id,
|
|
error=str(e))
|
|
return error_response("Failed to get NPC", 500)
|
|
|
|
|
|
@npcs_bp.route('/api/v1/npcs/<npc_id>/talk', methods=['POST'])
|
|
@require_auth
|
|
def talk_to_npc(npc_id: str):
|
|
"""
|
|
Initiate conversation with an NPC.
|
|
|
|
Validates NPC is at current location, updates interaction state,
|
|
and queues AI dialogue generation task.
|
|
|
|
Path params:
|
|
npc_id: NPC ID to talk to
|
|
|
|
Request body:
|
|
session_id: Active session ID
|
|
topic: Conversation topic/opener (default: "greeting")
|
|
player_response: What the player says to the NPC (overrides topic if provided)
|
|
|
|
Returns:
|
|
JSON response with job_id for polling result
|
|
"""
|
|
try:
|
|
user = get_current_user()
|
|
data = request.get_json()
|
|
|
|
session_id = data.get('session_id')
|
|
# player_response overrides topic for bidirectional dialogue
|
|
player_response = data.get('player_response')
|
|
topic = player_response if player_response else data.get('topic', 'greeting')
|
|
|
|
if not session_id:
|
|
return validation_error_response("session_id is required")
|
|
|
|
# Get session
|
|
session_service = get_session_service()
|
|
session = session_service.get_session(session_id, user.id)
|
|
|
|
# Load NPC
|
|
npc_loader = get_npc_loader()
|
|
npc = npc_loader.load_npc(npc_id)
|
|
|
|
if not npc:
|
|
return not_found_response("NPC not found")
|
|
|
|
# Validate NPC is at current location
|
|
if npc.location_id != session.game_state.current_location:
|
|
logger.warning("NPC not at current location",
|
|
npc_id=npc_id,
|
|
npc_location=npc.location_id,
|
|
current_location=session.game_state.current_location)
|
|
return error_response("NPC is not at your current location", 400)
|
|
|
|
# Get character
|
|
character_service = get_character_service()
|
|
character = character_service.get_character(session.solo_character_id, user.id)
|
|
|
|
# Get or create interaction state
|
|
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
interaction = character.npc_interactions.get(npc_id, {})
|
|
|
|
if not interaction:
|
|
# First meeting
|
|
interaction = {
|
|
"npc_id": npc_id,
|
|
"first_met": now,
|
|
"last_interaction": now,
|
|
"interaction_count": 1,
|
|
"revealed_secrets": [],
|
|
"relationship_level": 50,
|
|
"custom_flags": {},
|
|
}
|
|
else:
|
|
# Update existing interaction
|
|
interaction["last_interaction"] = now
|
|
interaction["interaction_count"] = interaction.get("interaction_count", 0) + 1
|
|
|
|
# Check for newly revealed secrets
|
|
revealed = character_service.check_npc_secret_conditions(character, npc)
|
|
|
|
# Update character with new interaction state
|
|
character_service.update_npc_interaction(
|
|
character.character_id,
|
|
user.id,
|
|
npc_id,
|
|
interaction
|
|
)
|
|
|
|
# Build NPC knowledge for AI context
|
|
npc_knowledge = []
|
|
if npc.knowledge:
|
|
npc_knowledge.extend(npc.knowledge.public)
|
|
npc_knowledge.extend(revealed)
|
|
|
|
# Get previous dialogue history for context (last 3 exchanges)
|
|
previous_dialogue = character_service.get_npc_dialogue_history(
|
|
character.character_id,
|
|
user.id,
|
|
npc_id,
|
|
limit=3
|
|
)
|
|
|
|
# Prepare AI context
|
|
task_context = {
|
|
"session_id": session_id,
|
|
"character_id": character.character_id,
|
|
"character": character.to_story_dict(),
|
|
"npc": npc.to_story_dict(),
|
|
"npc_full": npc.to_dict(), # Full NPC data for reference
|
|
"conversation_topic": topic,
|
|
"game_state": session.game_state.to_dict(),
|
|
"npc_knowledge": npc_knowledge,
|
|
"revealed_secrets": revealed,
|
|
"interaction_count": interaction["interaction_count"],
|
|
"relationship_level": interaction.get("relationship_level", 50),
|
|
"previous_dialogue": previous_dialogue, # Pass conversation history
|
|
}
|
|
|
|
# Enqueue AI task
|
|
result = enqueue_ai_task(
|
|
task_type=TaskType.NPC_DIALOGUE,
|
|
user_id=user.id,
|
|
context=task_context,
|
|
priority="normal",
|
|
session_id=session_id,
|
|
character_id=character.character_id
|
|
)
|
|
|
|
logger.info("NPC dialogue task queued",
|
|
user_id=user.id,
|
|
npc_id=npc_id,
|
|
job_id=result.get('job_id'),
|
|
interaction_count=interaction["interaction_count"])
|
|
|
|
return accepted_response({
|
|
"job_id": result.get('job_id'),
|
|
"status": "queued",
|
|
"message": f"Starting conversation with {npc.name}...",
|
|
"npc_name": npc.name,
|
|
"npc_role": npc.role,
|
|
})
|
|
|
|
except SessionNotFound:
|
|
return not_found_response("Session not found")
|
|
except CharacterNotFound:
|
|
return not_found_response("Character not found")
|
|
except Exception as e:
|
|
logger.error("Failed to talk to NPC",
|
|
npc_id=npc_id,
|
|
error=str(e))
|
|
return error_response("Failed to start conversation", 500)
|
|
|
|
|
|
@npcs_bp.route('/api/v1/npcs/at-location/<location_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_npcs_at_location(location_id: str):
|
|
"""
|
|
Get all NPCs at a specific location.
|
|
|
|
Path params:
|
|
location_id: Location ID to get NPCs for
|
|
|
|
Returns:
|
|
JSON response with list of NPCs at location
|
|
"""
|
|
try:
|
|
npc_loader = get_npc_loader()
|
|
npcs = npc_loader.get_npcs_at_location(location_id)
|
|
|
|
npcs_list = []
|
|
for npc in npcs:
|
|
npcs_list.append({
|
|
"npc_id": npc.npc_id,
|
|
"name": npc.name,
|
|
"role": npc.role,
|
|
"appearance": npc.appearance.brief,
|
|
"tags": npc.tags,
|
|
"image_url": npc.image_url,
|
|
})
|
|
|
|
return success_response({
|
|
"location_id": location_id,
|
|
"npcs": npcs_list,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get NPCs at location",
|
|
location_id=location_id,
|
|
error=str(e))
|
|
return error_response("Failed to get NPCs", 500)
|
|
|
|
|
|
@npcs_bp.route('/api/v1/npcs/<npc_id>/relationship', methods=['POST'])
|
|
@require_auth
|
|
def adjust_npc_relationship(npc_id: str):
|
|
"""
|
|
Adjust relationship level with an NPC.
|
|
|
|
Path params:
|
|
npc_id: NPC ID
|
|
|
|
Request body:
|
|
character_id: Character ID
|
|
adjustment: Amount to add/subtract (can be negative)
|
|
|
|
Returns:
|
|
JSON response with updated relationship level
|
|
"""
|
|
try:
|
|
user = get_current_user()
|
|
data = request.get_json()
|
|
|
|
character_id = data.get('character_id')
|
|
adjustment = data.get('adjustment', 0)
|
|
|
|
if not character_id:
|
|
return validation_error_response("character_id is required")
|
|
|
|
if not isinstance(adjustment, int):
|
|
return validation_error_response("adjustment must be an integer")
|
|
|
|
# Validate NPC exists
|
|
npc_loader = get_npc_loader()
|
|
npc = npc_loader.load_npc(npc_id)
|
|
|
|
if not npc:
|
|
return not_found_response("NPC not found")
|
|
|
|
# Adjust relationship
|
|
character_service = get_character_service()
|
|
character = character_service.adjust_npc_relationship(
|
|
character_id,
|
|
user.id,
|
|
npc_id,
|
|
adjustment
|
|
)
|
|
|
|
new_level = character.npc_interactions.get(npc_id, {}).get("relationship_level", 50)
|
|
|
|
logger.info("NPC relationship adjusted",
|
|
npc_id=npc_id,
|
|
character_id=character_id,
|
|
adjustment=adjustment,
|
|
new_level=new_level)
|
|
|
|
return success_response({
|
|
"npc_id": npc_id,
|
|
"relationship_level": new_level,
|
|
})
|
|
|
|
except CharacterNotFound:
|
|
return not_found_response("Character not found")
|
|
except Exception as e:
|
|
logger.error("Failed to adjust NPC relationship",
|
|
npc_id=npc_id,
|
|
error=str(e))
|
|
return error_response("Failed to adjust relationship", 500)
|
|
|
|
|
|
@npcs_bp.route('/api/v1/npcs/<npc_id>/flag', methods=['POST'])
|
|
@require_auth
|
|
def set_npc_flag(npc_id: str):
|
|
"""
|
|
Set a custom flag on NPC interaction (e.g., "helped_with_rats": true).
|
|
|
|
Path params:
|
|
npc_id: NPC ID
|
|
|
|
Request body:
|
|
character_id: Character ID
|
|
flag_name: Name of the flag
|
|
flag_value: Value to set
|
|
|
|
Returns:
|
|
JSON response confirming flag was set
|
|
"""
|
|
try:
|
|
user = get_current_user()
|
|
data = request.get_json()
|
|
|
|
character_id = data.get('character_id')
|
|
flag_name = data.get('flag_name')
|
|
flag_value = data.get('flag_value')
|
|
|
|
if not character_id:
|
|
return validation_error_response("character_id is required")
|
|
if not flag_name:
|
|
return validation_error_response("flag_name is required")
|
|
|
|
# Validate NPC exists
|
|
npc_loader = get_npc_loader()
|
|
npc = npc_loader.load_npc(npc_id)
|
|
|
|
if not npc:
|
|
return not_found_response("NPC not found")
|
|
|
|
# Set flag
|
|
character_service = get_character_service()
|
|
character_service.set_npc_custom_flag(
|
|
character_id,
|
|
user.id,
|
|
npc_id,
|
|
flag_name,
|
|
flag_value
|
|
)
|
|
|
|
logger.info("NPC flag set",
|
|
npc_id=npc_id,
|
|
character_id=character_id,
|
|
flag_name=flag_name)
|
|
|
|
return success_response({
|
|
"npc_id": npc_id,
|
|
"flag_name": flag_name,
|
|
"flag_value": flag_value,
|
|
})
|
|
|
|
except CharacterNotFound:
|
|
return not_found_response("Character not found")
|
|
except Exception as e:
|
|
logger.error("Failed to set NPC flag",
|
|
npc_id=npc_id,
|
|
error=str(e))
|
|
return error_response("Failed to set flag", 500)
|