first commit
This commit is contained in:
429
api/app/api/npcs.py
Normal file
429
api/app/api/npcs.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
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,
|
||||
})
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user