feat: Implement Phase 5 Quest System (100% complete)
Add YAML-driven quest system with context-aware offering:
Core Implementation:
- Quest data models (Quest, QuestObjective, QuestReward, QuestTriggers)
- QuestService for YAML loading and caching
- QuestEligibilityService with level, location, and probability filtering
- LoreService stub (MockLoreService) ready for Phase 6 Weaviate integration
Quest Content:
- 5 example quests across difficulty tiers (2 easy, 2 medium, 1 hard)
- Quest-centric design: quests define their NPC givers
- Location-based probability weights for natural quest offering
AI Integration:
- Quest offering section in npc_dialogue.j2 template
- Response parser extracts [QUEST_OFFER:quest_id] markers
- AI naturally weaves quest offers into NPC conversations
API Endpoints:
- POST /api/v1/quests/accept - Accept quest offer
- POST /api/v1/quests/decline - Decline quest offer
- POST /api/v1/quests/progress - Update objective progress
- POST /api/v1/quests/complete - Complete quest, claim rewards
- POST /api/v1/quests/abandon - Abandon active quest
- GET /api/v1/characters/{id}/quests - List character quests
- GET /api/v1/quests/{quest_id} - Get quest details
Frontend:
- Quest tracker sidebar with HTMX integration
- Quest offer modal for accept/decline flow
- Quest detail modal for viewing progress
- Combat service integration for kill objective tracking
Testing:
- Unit tests for Quest models and serialization
- Integration tests for full quest lifecycle
- Comprehensive test coverage for eligibility service
Documentation:
- Reorganized docs into /docs/phases/ structure
- Added Phase 5-12 planning documents
- Updated ROADMAP.md with new structure
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,11 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(shop_bp)
|
||||
logger.info("Shop API blueprint registered")
|
||||
|
||||
# Import and register Quests API blueprint
|
||||
from app.api.quests import quests_bp
|
||||
app.register_blueprint(quests_bp)
|
||||
logger.info("Quests API blueprint registered")
|
||||
|
||||
# TODO: Register additional blueprints as they are created
|
||||
# from app.api import marketplace
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
|
||||
@@ -447,7 +447,8 @@ class NarrativeGenerator:
|
||||
user_tier: UserTier,
|
||||
npc_relationship: str | None = None,
|
||||
previous_dialogue: list[dict[str, Any]] | None = None,
|
||||
npc_knowledge: list[str] | None = None
|
||||
npc_knowledge: list[str] | None = None,
|
||||
quest_offering_context: dict[str, Any] | None = None
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate NPC dialogue in response to player conversation.
|
||||
@@ -461,6 +462,7 @@ class NarrativeGenerator:
|
||||
npc_relationship: Optional description of relationship with NPC.
|
||||
previous_dialogue: Optional list of previous exchanges.
|
||||
npc_knowledge: Optional list of things this NPC knows about.
|
||||
quest_offering_context: Optional quest offer context from QuestEligibilityService.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with NPC dialogue.
|
||||
@@ -500,6 +502,7 @@ class NarrativeGenerator:
|
||||
npc_relationship=npc_relationship,
|
||||
previous_dialogue=previous_dialogue or [],
|
||||
npc_knowledge=npc_knowledge or [],
|
||||
quest_offering_context=quest_offering_context,
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
|
||||
@@ -4,8 +4,11 @@ Response parser for AI narrative responses.
|
||||
This module handles AI response parsing. Game state changes (items, gold, XP)
|
||||
are now handled exclusively through predetermined dice check outcomes in
|
||||
action templates, not through AI-generated JSON.
|
||||
|
||||
Quest offers are extracted from NPC dialogue using [QUEST_OFFER:quest_id] markers.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -158,3 +161,83 @@ def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges:
|
||||
changes.location_change = data.get("location_change")
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NPC Dialogue Parsing
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class ParsedNPCDialogue:
|
||||
"""
|
||||
Parsed NPC dialogue response with quest offer extraction.
|
||||
|
||||
When NPCs offer quests during conversation, they include a
|
||||
[QUEST_OFFER:quest_id] marker that signals the UI to show
|
||||
a quest accept/decline modal.
|
||||
|
||||
Attributes:
|
||||
dialogue: The cleaned dialogue text (marker removed)
|
||||
quest_offered: Quest ID if a quest was offered, None otherwise
|
||||
raw_response: The original response text
|
||||
"""
|
||||
|
||||
dialogue: str
|
||||
quest_offered: Optional[str] = None
|
||||
raw_response: str = ""
|
||||
|
||||
|
||||
# Regex pattern for quest offer markers
|
||||
# Matches: [QUEST_OFFER:quest_id] or [QUEST_OFFER: quest_id]
|
||||
QUEST_OFFER_PATTERN = re.compile(r'\[QUEST_OFFER:\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\]')
|
||||
|
||||
|
||||
def parse_npc_dialogue(response_text: str) -> ParsedNPCDialogue:
|
||||
"""
|
||||
Parse an NPC dialogue response, extracting quest offer markers.
|
||||
|
||||
The AI is instructed to include [QUEST_OFFER:quest_id] on its own line
|
||||
when offering a quest. This function extracts the marker and returns
|
||||
the cleaned dialogue.
|
||||
|
||||
Args:
|
||||
response_text: The raw AI dialogue response
|
||||
|
||||
Returns:
|
||||
ParsedNPCDialogue with cleaned dialogue and optional quest_offered
|
||||
|
||||
Example:
|
||||
>>> response = '''*leans in* Got a problem, friend.
|
||||
... [QUEST_OFFER:quest_cellar_rats]
|
||||
... Giant rats in me cellar.'''
|
||||
>>> result = parse_npc_dialogue(response)
|
||||
>>> result.quest_offered
|
||||
'quest_cellar_rats'
|
||||
>>> '[QUEST_OFFER' in result.dialogue
|
||||
False
|
||||
"""
|
||||
logger.debug("Parsing NPC dialogue", response_length=len(response_text))
|
||||
|
||||
quest_offered = None
|
||||
dialogue = response_text.strip()
|
||||
|
||||
# Search for quest offer marker
|
||||
match = QUEST_OFFER_PATTERN.search(dialogue)
|
||||
if match:
|
||||
quest_offered = match.group(1)
|
||||
# Remove the marker from the dialogue
|
||||
dialogue = QUEST_OFFER_PATTERN.sub('', dialogue)
|
||||
# Clean up any extra whitespace/newlines left behind
|
||||
dialogue = re.sub(r'\n\s*\n', '\n\n', dialogue)
|
||||
dialogue = dialogue.strip()
|
||||
|
||||
logger.info(
|
||||
"Quest offer extracted from dialogue",
|
||||
quest_id=quest_offered,
|
||||
)
|
||||
|
||||
return ParsedNPCDialogue(
|
||||
dialogue=dialogue,
|
||||
quest_offered=quest_offered,
|
||||
raw_response=response_text,
|
||||
)
|
||||
|
||||
@@ -92,6 +92,67 @@ Work these into the dialogue naturally - don't dump all information at once.
|
||||
Make it feel earned, like the NPC is opening up to someone they trust.
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context and quest_offering_context.should_offer %}
|
||||
## QUEST OFFERING OPPORTUNITY
|
||||
The NPC has a quest to offer. Weave this naturally into the conversation.
|
||||
|
||||
**Quest:** {{ quest_offering_context.quest_name }}
|
||||
**Quest ID:** {{ quest_offering_context.quest_id }}
|
||||
|
||||
{% if quest_offering_context.offer_dialogue %}
|
||||
**How the NPC Would Present It:**
|
||||
{{ quest_offering_context.offer_dialogue }}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context.npc_quest_knowledge %}
|
||||
**What the NPC Knows About This Quest:**
|
||||
{% for fact in quest_offering_context.npc_quest_knowledge %}
|
||||
- {{ fact }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context.narrative_hooks %}
|
||||
**Narrative Hooks (use 1-2 naturally):**
|
||||
{% for hook in quest_offering_context.narrative_hooks %}
|
||||
- The NPC {{ hook }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
**IMPORTANT QUEST OFFERING RULES:**
|
||||
- Do NOT dump all quest information at once
|
||||
- Let the quest emerge naturally from conversation
|
||||
- If the player seems interested or asks about problems/work, offer the quest
|
||||
- If the player changes topic, don't force it - just mention hints
|
||||
- When you offer the quest, include this marker on its own line: [QUEST_OFFER:{{ quest_offering_context.quest_id }}]
|
||||
- The marker signals the UI to show a quest accept/decline option
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context and lore_context.has_content %}
|
||||
## Relevant World Knowledge
|
||||
The NPC may reference this lore if contextually appropriate:
|
||||
|
||||
{% if lore_context.quest %}
|
||||
**Quest Background:**
|
||||
{% for entry in lore_context.quest[:2] %}
|
||||
- {{ entry.content | truncate_text(150) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context.regional %}
|
||||
**Local Knowledge:**
|
||||
{% for entry in lore_context.regional[:3] %}
|
||||
- {{ entry.content | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context.world %}
|
||||
**Historical Knowledge (if NPC would know):**
|
||||
{% for entry in lore_context.world[:2] %}
|
||||
- {{ entry.content | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.relationships %}
|
||||
## NPC Relationships (for context)
|
||||
{% for rel in npc.relationships %}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.services.quest_eligibility_service import get_quest_eligibility_service
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
@@ -192,6 +193,35 @@ def talk_to_npc(npc_id: str):
|
||||
interaction
|
||||
)
|
||||
|
||||
# Check for quest eligibility
|
||||
quest_offering_context = None
|
||||
try:
|
||||
quest_eligibility_service = get_quest_eligibility_service()
|
||||
location_type = _get_location_type(session.game_state.current_location)
|
||||
|
||||
eligibility_result = quest_eligibility_service.check_eligibility(
|
||||
npc_id=npc_id,
|
||||
character=character,
|
||||
location_type=location_type,
|
||||
location_id=session.game_state.current_location
|
||||
)
|
||||
|
||||
if eligibility_result.should_offer_quest and eligibility_result.selected_quest_context:
|
||||
quest_offering_context = eligibility_result.selected_quest_context.to_dict()
|
||||
logger.debug(
|
||||
"Quest eligible for offering",
|
||||
npc_id=npc_id,
|
||||
quest_id=quest_offering_context.get("quest_id"),
|
||||
character_id=character.character_id
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail the conversation if quest eligibility check fails
|
||||
logger.warning(
|
||||
"Quest eligibility check failed",
|
||||
npc_id=npc_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Build NPC knowledge for AI context
|
||||
npc_knowledge = []
|
||||
if npc.knowledge:
|
||||
@@ -220,6 +250,7 @@ def talk_to_npc(npc_id: str):
|
||||
"interaction_count": interaction["interaction_count"],
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"previous_dialogue": previous_dialogue, # Pass conversation history
|
||||
"quest_offering_context": quest_offering_context, # Quest offer if eligible
|
||||
}
|
||||
|
||||
# Enqueue AI task
|
||||
@@ -428,3 +459,27 @@ def set_npc_flag(npc_id: str):
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to set flag", 500)
|
||||
|
||||
|
||||
def _get_location_type(location_id: str) -> str:
|
||||
"""
|
||||
Extract location type from location_id for quest probability calculation.
|
||||
|
||||
Args:
|
||||
location_id: The location identifier string
|
||||
|
||||
Returns:
|
||||
Location type string (tavern, shop, wilderness, dungeon, or town)
|
||||
"""
|
||||
location_lower = location_id.lower()
|
||||
|
||||
if "tavern" in location_lower or "inn" in location_lower:
|
||||
return "tavern"
|
||||
elif "shop" in location_lower or "market" in location_lower or "store" in location_lower:
|
||||
return "shop"
|
||||
elif "wilderness" in location_lower or "forest" in location_lower or "road" in location_lower:
|
||||
return "wilderness"
|
||||
elif "dungeon" in location_lower or "cave" in location_lower or "mine" in location_lower:
|
||||
return "dungeon"
|
||||
|
||||
return "town" # Default for town centers, squares, etc.
|
||||
|
||||
724
api/app/api/quests.py
Normal file
724
api/app/api/quests.py
Normal file
@@ -0,0 +1,724 @@
|
||||
"""
|
||||
Quest API endpoints for quest management.
|
||||
|
||||
This module provides REST endpoints for:
|
||||
- Accepting offered quests
|
||||
- Declining offered quests
|
||||
- Getting quest details
|
||||
- Listing character quests
|
||||
- Completing quests (internal use)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, request, jsonify
|
||||
import structlog
|
||||
|
||||
from app.utils.response import api_response, error_response
|
||||
from app.utils.auth import require_auth
|
||||
from app.services.quest_service import get_quest_service
|
||||
from app.services.quest_eligibility_service import get_quest_eligibility_service
|
||||
from app.services.character_service import get_character_service
|
||||
from app.models.quest import QuestStatus, CharacterQuestState
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
quests_bp = Blueprint('quests', __name__, url_prefix='/api/v1/quests')
|
||||
|
||||
|
||||
@quests_bp.route('/<quest_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_quest(user_id: str, quest_id: str):
|
||||
"""
|
||||
Get details for a specific quest.
|
||||
|
||||
Args:
|
||||
quest_id: Quest identifier
|
||||
|
||||
Returns:
|
||||
Quest details or 404 if not found
|
||||
"""
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data=quest.to_offer_dict(),
|
||||
message="Quest details retrieved",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/accept', methods=['POST'])
|
||||
@require_auth
|
||||
def accept_quest(user_id: str):
|
||||
"""
|
||||
Accept an offered quest.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"npc_id": "npc_grom_ironbeard" # Optional: NPC who offered the quest
|
||||
}
|
||||
|
||||
Returns:
|
||||
Updated character quest state
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
npc_id = data.get('npc_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Get quest
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if already at max quests
|
||||
if len(character.active_quests) >= 2:
|
||||
return error_response(
|
||||
message="Maximum active quests reached (2)",
|
||||
status=400,
|
||||
code="MAX_QUESTS_REACHED",
|
||||
)
|
||||
|
||||
# Check if quest is already active
|
||||
if quest_id in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is already active",
|
||||
status=400,
|
||||
code="QUEST_ALREADY_ACTIVE",
|
||||
)
|
||||
|
||||
# Check if quest is already completed
|
||||
completed_quests = getattr(character, 'completed_quests', [])
|
||||
if quest_id in completed_quests:
|
||||
return error_response(
|
||||
message="Quest has already been completed",
|
||||
status=400,
|
||||
code="QUEST_ALREADY_COMPLETED",
|
||||
)
|
||||
|
||||
# Add quest to active quests
|
||||
character.active_quests.append(quest_id)
|
||||
|
||||
# Create quest state tracking
|
||||
quest_state = CharacterQuestState(
|
||||
quest_id=quest_id,
|
||||
status=QuestStatus.ACTIVE,
|
||||
accepted_at=datetime.now(timezone.utc).isoformat(),
|
||||
objectives_progress={
|
||||
obj.objective_id: 0 for obj in quest.objectives
|
||||
},
|
||||
)
|
||||
|
||||
# Store quest state in character (would normally go to database)
|
||||
if not hasattr(character, 'quest_states'):
|
||||
character.quest_states = {}
|
||||
character.quest_states[quest_id] = quest_state.to_dict()
|
||||
|
||||
# Update NPC relationship if NPC provided
|
||||
if npc_id and npc_id in character.npc_interactions:
|
||||
npc_interaction = character.npc_interactions[npc_id]
|
||||
current_relationship = npc_interaction.get('relationship_level', 50)
|
||||
npc_interaction['relationship_level'] = min(100, current_relationship + 5)
|
||||
# Set accepted flag
|
||||
if 'custom_flags' not in npc_interaction:
|
||||
npc_interaction['custom_flags'] = {}
|
||||
npc_interaction['custom_flags'][f'accepted_{quest_id}'] = True
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest accepted",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
npc_id=npc_id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"quest_name": quest.name,
|
||||
"active_quests": character.active_quests,
|
||||
"quest_state": quest_state.to_dict(),
|
||||
},
|
||||
message=f"Quest accepted: {quest.name}",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/decline', methods=['POST'])
|
||||
@require_auth
|
||||
def decline_quest(user_id: str):
|
||||
"""
|
||||
Decline an offered quest.
|
||||
|
||||
Sets a flag on the NPC interaction to prevent immediate re-offering.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"npc_id": "npc_grom_ironbeard"
|
||||
}
|
||||
|
||||
Returns:
|
||||
Confirmation of decline
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
npc_id = data.get('npc_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Set declined flag on NPC interaction
|
||||
if npc_id:
|
||||
if npc_id not in character.npc_interactions:
|
||||
character.npc_interactions[npc_id] = {
|
||||
'npc_id': npc_id,
|
||||
'relationship_level': 50,
|
||||
'custom_flags': {},
|
||||
}
|
||||
|
||||
npc_interaction = character.npc_interactions[npc_id]
|
||||
if 'custom_flags' not in npc_interaction:
|
||||
npc_interaction['custom_flags'] = {}
|
||||
|
||||
# Set declined flag - this will be checked by quest eligibility
|
||||
npc_interaction['custom_flags'][f'declined_{quest_id}'] = True
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest declined",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
npc_id=npc_id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"declined": True,
|
||||
},
|
||||
message="Quest declined",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/characters/<character_id>/quests', methods=['GET'])
|
||||
@require_auth
|
||||
def get_character_quests(user_id: str, character_id: str):
|
||||
"""
|
||||
Get a character's active and completed quests.
|
||||
|
||||
Args:
|
||||
character_id: Character identifier
|
||||
|
||||
Returns:
|
||||
Lists of active and completed quests with details
|
||||
"""
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Get quest details for active quests
|
||||
quest_service = get_quest_service()
|
||||
active_quests = []
|
||||
for quest_id in character.active_quests:
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
if quest:
|
||||
quest_data = quest.to_offer_dict()
|
||||
# Add progress if available
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id in quest_states:
|
||||
quest_data['progress'] = quest_states[quest_id]
|
||||
active_quests.append(quest_data)
|
||||
|
||||
# Get completed quest IDs
|
||||
completed_quests = getattr(character, 'completed_quests', [])
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"active_quests": active_quests,
|
||||
"completed_quest_ids": completed_quests,
|
||||
"active_count": len(active_quests),
|
||||
"completed_count": len(completed_quests),
|
||||
},
|
||||
message="Character quests retrieved",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/complete', methods=['POST'])
|
||||
@require_auth
|
||||
def complete_quest(user_id: str):
|
||||
"""
|
||||
Complete a quest and grant rewards.
|
||||
|
||||
This endpoint is typically called by the game system when all
|
||||
objectives are completed, not directly by the player.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"npc_id": "npc_grom_ironbeard" # Optional: for reward dialogue
|
||||
}
|
||||
|
||||
Returns:
|
||||
Rewards granted and updated character state
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
npc_id = data.get('npc_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Check quest is active
|
||||
if quest_id not in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is not active for this character",
|
||||
status=400,
|
||||
code="QUEST_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
# Get quest for rewards
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Remove from active quests
|
||||
character.active_quests.remove(quest_id)
|
||||
|
||||
# Add to completed quests
|
||||
if not hasattr(character, 'completed_quests'):
|
||||
character.completed_quests = []
|
||||
character.completed_quests.append(quest_id)
|
||||
|
||||
# Grant rewards
|
||||
rewards = quest.rewards
|
||||
character.gold += rewards.gold
|
||||
leveled_up = character.add_experience(rewards.experience)
|
||||
|
||||
# Apply relationship bonuses
|
||||
for bonus_npc_id, bonus_amount in rewards.relationship_bonuses.items():
|
||||
if bonus_npc_id in character.npc_interactions:
|
||||
npc_interaction = character.npc_interactions[bonus_npc_id]
|
||||
current_relationship = npc_interaction.get('relationship_level', 50)
|
||||
npc_interaction['relationship_level'] = min(100, current_relationship + bonus_amount)
|
||||
|
||||
# Reveal locations
|
||||
for location_id in rewards.reveals_locations:
|
||||
if location_id not in character.discovered_locations:
|
||||
character.discovered_locations.append(location_id)
|
||||
|
||||
# Update quest state
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id in quest_states:
|
||||
quest_states[quest_id]['status'] = QuestStatus.COMPLETED.value
|
||||
quest_states[quest_id]['completed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
# Get completion dialogue if NPC provided
|
||||
completion_dialogue = ""
|
||||
if npc_id:
|
||||
completion_dialogue = quest.get_completion_dialogue(npc_id)
|
||||
|
||||
logger.info(
|
||||
"Quest completed",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
gold_granted=rewards.gold,
|
||||
xp_granted=rewards.experience,
|
||||
leveled_up=leveled_up,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"quest_name": quest.name,
|
||||
"rewards": {
|
||||
"gold": rewards.gold,
|
||||
"experience": rewards.experience,
|
||||
"items": rewards.items,
|
||||
"relationship_bonuses": rewards.relationship_bonuses,
|
||||
"reveals_locations": rewards.reveals_locations,
|
||||
},
|
||||
"leveled_up": leveled_up,
|
||||
"new_level": character.level if leveled_up else None,
|
||||
"completion_dialogue": completion_dialogue,
|
||||
},
|
||||
message=f"Quest completed: {quest.name}",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/progress', methods=['POST'])
|
||||
@require_auth
|
||||
def update_quest_progress(user_id: str):
|
||||
"""
|
||||
Update progress on a quest objective.
|
||||
|
||||
This endpoint is called when a player completes actions that contribute
|
||||
to quest objectives (kills enemies, collects items, etc.).
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"objective_id": "kill_rats",
|
||||
"amount": 1 # Optional, defaults to 1
|
||||
}
|
||||
|
||||
Returns:
|
||||
Updated progress state with completion flags
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
objective_id = data.get('objective_id')
|
||||
amount = data.get('amount', 1)
|
||||
|
||||
if not character_id or not quest_id or not objective_id:
|
||||
return error_response(
|
||||
message="character_id, quest_id, and objective_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Check quest is active
|
||||
if quest_id not in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is not active for this character",
|
||||
status=400,
|
||||
code="QUEST_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
# Get quest definition to validate objective and get required amount
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Find the objective
|
||||
objective = None
|
||||
for obj in quest.objectives:
|
||||
if obj.objective_id == objective_id:
|
||||
objective = obj
|
||||
break
|
||||
|
||||
if not objective:
|
||||
return error_response(
|
||||
message=f"Objective not found: {objective_id}",
|
||||
status=404,
|
||||
code="OBJECTIVE_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Get or create quest state
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id not in quest_states:
|
||||
# Initialize quest state if missing
|
||||
quest_states[quest_id] = {
|
||||
'quest_id': quest_id,
|
||||
'status': QuestStatus.ACTIVE.value,
|
||||
'accepted_at': datetime.now(timezone.utc).isoformat(),
|
||||
'objectives_progress': {obj.objective_id: 0 for obj in quest.objectives},
|
||||
'completed_at': None,
|
||||
}
|
||||
character.quest_states = quest_states
|
||||
|
||||
quest_state = quest_states[quest_id]
|
||||
objectives_progress = quest_state.get('objectives_progress', {})
|
||||
|
||||
# Update progress
|
||||
current_progress = objectives_progress.get(objective_id, 0)
|
||||
new_progress = min(current_progress + amount, objective.required_progress)
|
||||
objectives_progress[objective_id] = new_progress
|
||||
quest_state['objectives_progress'] = objectives_progress
|
||||
|
||||
# Check if this objective is complete
|
||||
objective_complete = new_progress >= objective.required_progress
|
||||
|
||||
# Check if entire quest is complete (all objectives met)
|
||||
quest_complete = True
|
||||
for obj in quest.objectives:
|
||||
obj_progress = objectives_progress.get(obj.objective_id, 0)
|
||||
if obj_progress < obj.required_progress:
|
||||
quest_complete = False
|
||||
break
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest progress updated",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
objective_id=objective_id,
|
||||
new_progress=new_progress,
|
||||
required=objective.required_progress,
|
||||
objective_complete=objective_complete,
|
||||
quest_complete=quest_complete,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"objective_id": objective_id,
|
||||
"new_progress": new_progress,
|
||||
"required": objective.required_progress,
|
||||
"objective_complete": objective_complete,
|
||||
"quest_complete": quest_complete,
|
||||
"all_progress": objectives_progress,
|
||||
},
|
||||
message=f"Progress updated: {new_progress}/{objective.required_progress}",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/abandon', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_quest(user_id: str):
|
||||
"""
|
||||
Abandon an active quest.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats"
|
||||
}
|
||||
|
||||
Returns:
|
||||
Confirmation of abandonment
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Check quest is active
|
||||
if quest_id not in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is not active for this character",
|
||||
status=400,
|
||||
code="QUEST_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
# Remove from active quests
|
||||
character.active_quests.remove(quest_id)
|
||||
|
||||
# Update quest state
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id in quest_states:
|
||||
quest_states[quest_id]['status'] = QuestStatus.FAILED.value
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest abandoned",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"abandoned": True,
|
||||
"active_quests": character.active_quests,
|
||||
},
|
||||
message="Quest abandoned",
|
||||
)
|
||||
@@ -81,9 +81,8 @@ dialogue_hooks:
|
||||
busy: "*keeps hammering* Talk while I work. Time is iron."
|
||||
quest_complete: "*nods approvingly* Fine work. You've got the heart of a warrior."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_ore_delivery
|
||||
- quest_equipment_repair
|
||||
# Note: Quest offerings are now defined in quest YAML files (quest-centric design)
|
||||
# See /api/app/data/quests/ for quest definitions that reference this NPC
|
||||
|
||||
reveals_locations: []
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ dialogue_hooks:
|
||||
busy: "Got thirsty folk to serve. Make it quick."
|
||||
quest_complete: "*actually smiles* Well done, lad. Drink's on the house."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_cellar_rats
|
||||
# Note: Quest offerings are now defined in quest YAML files (quest-centric design)
|
||||
# See /api/app/data/quests/ for quest definitions that reference this NPC
|
||||
|
||||
reveals_locations:
|
||||
- crossville_dungeon
|
||||
|
||||
@@ -71,9 +71,8 @@ dialogue_hooks:
|
||||
busy: "*distracted* I have urgent matters to attend. Perhaps later?"
|
||||
quest_complete: "*genuine relief* You have done Crossville a great service."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_mayors_request
|
||||
- quest_bandit_threat
|
||||
# Note: Quest offerings are now defined in quest YAML files (quest-centric design)
|
||||
# See /api/app/data/quests/ for quest definitions that reference this NPC
|
||||
|
||||
reveals_locations:
|
||||
- crossville_dungeon
|
||||
|
||||
@@ -76,8 +76,8 @@ dialogue_hooks:
|
||||
busy: "*glances at the door* Not now. Later."
|
||||
quest_complete: "*grins* You've got potential. Stick around."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_bandit_camp
|
||||
# Note: Quest offerings are now defined in quest YAML files (quest-centric design)
|
||||
# See /api/app/data/quests/ for quest definitions that reference this NPC
|
||||
|
||||
reveals_locations:
|
||||
- crossville_forest
|
||||
|
||||
109
api/app/data/quests/easy/cellar_rats.yaml
Normal file
109
api/app/data/quests/easy/cellar_rats.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
# Cellar Rats - Entry level quest for new players
|
||||
quest_id: quest_cellar_rats
|
||||
name: "Rat Problem in the Cellar"
|
||||
description: |
|
||||
Giant rats have infested the Rusty Anchor's cellar, threatening
|
||||
the tavern's food supplies and scaring away customers. Grom the
|
||||
bartender needs someone to clear them out.
|
||||
difficulty: easy
|
||||
|
||||
# NPC Quest Givers (quest-centric design)
|
||||
quest_giver_npc_ids:
|
||||
- npc_grom_ironbeard
|
||||
quest_giver_name: "Grom Ironbeard"
|
||||
|
||||
# Location context
|
||||
location_id: crossville_tavern
|
||||
region_id: crossville
|
||||
|
||||
# Offering conditions
|
||||
offering_triggers:
|
||||
location_types: ["tavern"]
|
||||
specific_locations: ["crossville_tavern"]
|
||||
min_character_level: 1
|
||||
max_character_level: 5
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
tavern: 0.35
|
||||
town: 0.15
|
||||
|
||||
# NPC-specific offer dialogue
|
||||
npc_offer_dialogues:
|
||||
npc_grom_ironbeard:
|
||||
dialogue: |
|
||||
*leans in conspiratorially* Got a problem, friend. Giant rats
|
||||
in me cellar. Been scaring off the paying customers and getting
|
||||
into the ale stores. 50 gold for whoever clears 'em out.
|
||||
conditions:
|
||||
min_relationship: 30
|
||||
required_flags: []
|
||||
forbidden_flags: ["refused_rat_quest"]
|
||||
|
||||
# What NPCs know about this quest
|
||||
npc_quest_knowledge:
|
||||
npc_grom_ironbeard:
|
||||
- "The rats started appearing about a week ago"
|
||||
- "They seem bigger than normal rats - something's not right"
|
||||
- "Old smuggling tunnels under the cellar might be where they're coming from"
|
||||
- "Lost three kegs of dwarven stout to the vermin already"
|
||||
|
||||
# Embedded lore for AI context
|
||||
lore_context:
|
||||
backstory: |
|
||||
The cellar of the Rusty Anchor connects to old smuggling tunnels
|
||||
from Captain Morgath's days, sealed decades ago. Recent earthquakes
|
||||
may have reopened them, allowing creatures to emerge.
|
||||
world_connections:
|
||||
- "The earthquakes that opened the tunnels also disturbed the Old Mines"
|
||||
- "Giant rats are unnatural - possibly corrupted by dark magic from below"
|
||||
regional_hints:
|
||||
- "Smuggling was common 50 years ago in Crossville"
|
||||
- "Captain Morgath was a notorious smuggler who disappeared mysteriously"
|
||||
|
||||
# Narrative hooks for natural dialogue
|
||||
dialogue_templates:
|
||||
narrative_hooks:
|
||||
- "mentions unusual scratching sounds from below"
|
||||
- "complains about spoiled food supplies"
|
||||
- "nervously glances toward the cellar door"
|
||||
- "asks if you've ever dealt with vermin before"
|
||||
ambient_hints:
|
||||
- "You notice scratch marks near the cellar door"
|
||||
- "A faint squeaking echoes from somewhere below"
|
||||
- "The air carries a musty, unpleasant odor"
|
||||
|
||||
# Objectives
|
||||
objectives:
|
||||
- objective_id: kill_rats
|
||||
description: "Clear out the giant rats"
|
||||
objective_type: kill
|
||||
required_progress: 10
|
||||
target_enemy_type: giant_rat
|
||||
|
||||
- objective_id: find_source
|
||||
description: "Discover where the rats are coming from"
|
||||
objective_type: discover
|
||||
required_progress: 1
|
||||
target_location_id: smuggler_tunnel_entrance
|
||||
|
||||
# Rewards
|
||||
rewards:
|
||||
gold: 50
|
||||
experience: 100
|
||||
items: []
|
||||
relationship_bonuses:
|
||||
npc_grom_ironbeard: 10
|
||||
unlocks_quests: ["quest_tunnel_mystery"]
|
||||
reveals_locations: ["crossville_tunnels"]
|
||||
|
||||
# Completion dialogue
|
||||
completion_dialogue:
|
||||
npc_grom_ironbeard: |
|
||||
*actually smiles* Well done, friend! Rats are gone, cellar's safe.
|
||||
Here's your coin, well earned. Drink's on the house tonight.
|
||||
*pauses* You say you found old tunnels down there? Interesting...
|
||||
|
||||
tags:
|
||||
- starter
|
||||
- combat
|
||||
- exploration
|
||||
109
api/app/data/quests/easy/missing_tools.yaml
Normal file
109
api/app/data/quests/easy/missing_tools.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
# Missing Tools - Simple fetch quest for new players
|
||||
quest_id: quest_missing_tools
|
||||
name: "The Blacksmith's Missing Tools"
|
||||
description: |
|
||||
Hilda the blacksmith's best hammers and tongs have gone missing.
|
||||
She suspects they were stolen by mischievous goblins who've been
|
||||
spotted near the forest edge.
|
||||
difficulty: easy
|
||||
|
||||
# NPC Quest Givers
|
||||
quest_giver_npc_ids:
|
||||
- npc_blacksmith_hilda
|
||||
quest_giver_name: "Hilda Stoneforge"
|
||||
|
||||
# Location context
|
||||
location_id: crossville_forge
|
||||
region_id: crossville
|
||||
|
||||
# Offering conditions
|
||||
offering_triggers:
|
||||
location_types: ["town", "shop"]
|
||||
specific_locations: ["crossville_forge", "crossville_village"]
|
||||
min_character_level: 1
|
||||
max_character_level: 4
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
town: 0.25
|
||||
shop: 0.30
|
||||
|
||||
# NPC-specific offer dialogue
|
||||
npc_offer_dialogues:
|
||||
npc_blacksmith_hilda:
|
||||
dialogue: |
|
||||
*sighs heavily* Some thieving little beasts made off with me
|
||||
best tools last night. Saw goblin tracks leading toward the
|
||||
forest. Can't do proper work without 'em. Help me out?
|
||||
conditions:
|
||||
min_relationship: 20
|
||||
required_flags: []
|
||||
forbidden_flags: []
|
||||
|
||||
# What NPCs know about this quest
|
||||
npc_quest_knowledge:
|
||||
npc_blacksmith_hilda:
|
||||
- "The tools were passed down from her father"
|
||||
- "Goblins have been more active lately near the forest"
|
||||
- "She heard strange noises two nights ago but thought it was just animals"
|
||||
- "The tools are distinctive - marked with the Stoneforge family crest"
|
||||
|
||||
# Embedded lore
|
||||
lore_context:
|
||||
backstory: |
|
||||
The Stoneforge family has been the village's blacksmiths for
|
||||
four generations. Hilda's tools are not just valuable - they're
|
||||
family heirlooms with sentimental worth.
|
||||
world_connections:
|
||||
- "Goblins typically avoid settlements unless driven by something"
|
||||
- "Recent disturbances in the region have made all creatures more bold"
|
||||
regional_hints:
|
||||
- "Goblin warrens exist in the rocky hills east of the forest"
|
||||
- "The Stoneforge family once forged weapons for the village militia"
|
||||
|
||||
# Narrative hooks
|
||||
dialogue_templates:
|
||||
narrative_hooks:
|
||||
- "mentions being unable to fill orders without proper tools"
|
||||
- "looks frustrated at a half-finished project"
|
||||
- "grumbles about creatures getting bolder"
|
||||
- "offers a discount on future work as additional thanks"
|
||||
ambient_hints:
|
||||
- "You notice the forge is quieter than usual"
|
||||
- "Several half-finished weapons sit abandoned on workbenches"
|
||||
|
||||
# Objectives
|
||||
objectives:
|
||||
- objective_id: track_goblins
|
||||
description: "Follow the goblin tracks into the forest"
|
||||
objective_type: travel
|
||||
required_progress: 1
|
||||
target_location_id: forest_goblin_camp
|
||||
|
||||
- objective_id: recover_tools
|
||||
description: "Recover Hilda's stolen tools"
|
||||
objective_type: collect
|
||||
required_progress: 1
|
||||
target_item_id: stoneforge_tools
|
||||
|
||||
# Rewards
|
||||
rewards:
|
||||
gold: 35
|
||||
experience: 75
|
||||
items: []
|
||||
relationship_bonuses:
|
||||
npc_blacksmith_hilda: 15
|
||||
unlocks_quests: []
|
||||
reveals_locations: ["forest_goblin_camp"]
|
||||
|
||||
# Completion dialogue
|
||||
completion_dialogue:
|
||||
npc_blacksmith_hilda: |
|
||||
*eyes light up* My tools! You found them! By the ancestors,
|
||||
I thought they were gone forever. Here's your coin, and know
|
||||
that you've got a friend at the Stoneforge. Come by anytime
|
||||
you need gear - I'll give you the family discount.
|
||||
|
||||
tags:
|
||||
- starter
|
||||
- fetch
|
||||
- exploration
|
||||
146
api/app/data/quests/hard/dungeon_depths.yaml
Normal file
146
api/app/data/quests/hard/dungeon_depths.yaml
Normal file
@@ -0,0 +1,146 @@
|
||||
# Dungeon Depths - Challenging dungeon crawl quest
|
||||
quest_id: quest_dungeon_depths
|
||||
name: "Secrets of the Old Mines"
|
||||
description: |
|
||||
The sealed entrance to Crossville's abandoned mines has been breached.
|
||||
Strange sounds and foul odors emanate from within, and livestock have
|
||||
begun to disappear. The mayor needs someone brave enough to investigate
|
||||
what ancient evil has awakened in the depths.
|
||||
difficulty: hard
|
||||
|
||||
# NPC Quest Givers
|
||||
quest_giver_npc_ids:
|
||||
- npc_mayor_aldric
|
||||
quest_giver_name: "Mayor Aldric Thornwood"
|
||||
|
||||
# Location context
|
||||
location_id: crossville_village
|
||||
region_id: crossville
|
||||
|
||||
# Offering conditions
|
||||
offering_triggers:
|
||||
location_types: ["town", "official"]
|
||||
specific_locations: ["crossville_village", "crossville_town_hall"]
|
||||
min_character_level: 6
|
||||
max_character_level: 15
|
||||
required_quests_completed:
|
||||
- quest_bandit_threat # Must have proven yourself
|
||||
probability_weights:
|
||||
town: 0.15
|
||||
official: 0.25
|
||||
|
||||
# NPC-specific offer dialogue
|
||||
npc_offer_dialogues:
|
||||
npc_mayor_aldric:
|
||||
dialogue: |
|
||||
*closes the door and speaks in hushed tones* What I'm about
|
||||
to tell you cannot leave this room. The Old Mines... they were
|
||||
sealed for a reason. My grandfather saw to it fifty years ago.
|
||||
Now something has broken through. I need someone I can trust
|
||||
to go down there and find out what we're dealing with.
|
||||
*pauses* And to ensure it stays buried.
|
||||
conditions:
|
||||
min_relationship: 60
|
||||
required_flags: ["completed_bandit_quest"]
|
||||
forbidden_flags: []
|
||||
|
||||
# What NPCs know
|
||||
npc_quest_knowledge:
|
||||
npc_mayor_aldric:
|
||||
- "The mines were sealed after a cave-in killed twenty miners"
|
||||
- "His grandfather's journal mentions 'that which must not wake'"
|
||||
- "The recent earthquakes may have disturbed ancient seals"
|
||||
- "He's being blackmailed by someone who knows the truth"
|
||||
- "The Thornwood family has guarded this secret for generations"
|
||||
|
||||
# Embedded lore
|
||||
lore_context:
|
||||
backstory: |
|
||||
Fifty years ago, miners in Crossville dug too deep and broke into
|
||||
ancient catacombs predating human settlement. What they found
|
||||
drove the survivors mad. The mines were sealed, the entrance
|
||||
buried, and the truth hidden. The Thornwood family has kept
|
||||
this secret ever since, fearing what might happen if the truth
|
||||
emerged.
|
||||
world_connections:
|
||||
- "The catacombs may predate the Five Kingdoms themselves"
|
||||
- "Similar sealed places exist across the realm - ancient evils best forgotten"
|
||||
- "The earthquakes affecting the region all seem centered on these old sites"
|
||||
regional_hints:
|
||||
- "Old-timers speak of the 'quiet years' after the mine closed"
|
||||
- "The Thornwood family rose to prominence around the same time"
|
||||
- "Some say the mayor's nightmares are why he never drinks"
|
||||
|
||||
# Narrative hooks
|
||||
dialogue_templates:
|
||||
narrative_hooks:
|
||||
- "speaks of family duty and burden"
|
||||
- "reveals more than intended when stressed"
|
||||
- "offers to share grandfather's journal"
|
||||
- "warns of dangers beyond mere monsters"
|
||||
ambient_hints:
|
||||
- "The mayor's hands shake when the mines are mentioned"
|
||||
- "Old maps on his wall show the mine entrance marked with warnings"
|
||||
- "A locked chest in the corner bears the Thornwood seal"
|
||||
|
||||
# Objectives
|
||||
objectives:
|
||||
- objective_id: enter_mines
|
||||
description: "Enter the Old Mines through the breached seal"
|
||||
objective_type: travel
|
||||
required_progress: 1
|
||||
target_location_id: old_mines_entrance
|
||||
|
||||
- objective_id: explore_depths
|
||||
description: "Explore the mine depths"
|
||||
objective_type: discover
|
||||
required_progress: 3
|
||||
target_location_id: mine_depths
|
||||
|
||||
- objective_id: defeat_guardians
|
||||
description: "Defeat the awakened guardians"
|
||||
objective_type: kill
|
||||
required_progress: 5
|
||||
target_enemy_type: ancient_guardian
|
||||
|
||||
- objective_id: find_crypt
|
||||
description: "Discover the ancient crypt"
|
||||
objective_type: discover
|
||||
required_progress: 1
|
||||
target_location_id: ancient_crypt
|
||||
|
||||
- objective_id: seal_evil
|
||||
description: "Reseal or destroy what lies within"
|
||||
objective_type: interact
|
||||
required_progress: 1
|
||||
target_npc_id: ancient_seal
|
||||
|
||||
# Rewards
|
||||
rewards:
|
||||
gold: 500
|
||||
experience: 750
|
||||
items:
|
||||
- ancient_medallion
|
||||
- thornwood_journal
|
||||
relationship_bonuses:
|
||||
npc_mayor_aldric: 30
|
||||
unlocks_quests: ["quest_ancient_truth"]
|
||||
reveals_locations: ["old_mines", "ancient_crypt"]
|
||||
|
||||
# Completion dialogue
|
||||
completion_dialogue:
|
||||
npc_mayor_aldric: |
|
||||
*sinks into his chair* It's done then? The seal holds?
|
||||
*long pause* You've done what my family couldn't for fifty
|
||||
years. You've seen what lies beneath our village. I trust
|
||||
you understand why this must remain secret.
|
||||
*slides a heavy chest across the desk*
|
||||
Your reward. And my eternal gratitude. The Thornwood family
|
||||
owes you a debt we can never fully repay. If you ever need
|
||||
anything... anything at all... my door is always open to you.
|
||||
|
||||
tags:
|
||||
- dungeon
|
||||
- combat
|
||||
- story
|
||||
- secret
|
||||
121
api/app/data/quests/medium/bandit_threat.yaml
Normal file
121
api/app/data/quests/medium/bandit_threat.yaml
Normal file
@@ -0,0 +1,121 @@
|
||||
# Bandit Threat - Combat-focused medium quest
|
||||
quest_id: quest_bandit_threat
|
||||
name: "The Bandit Threat"
|
||||
description: |
|
||||
A gang of bandits has been terrorizing travelers on the roads
|
||||
near Crossville. Mayor Aldric is offering a substantial reward
|
||||
for anyone who can deal with the threat permanently.
|
||||
difficulty: medium
|
||||
|
||||
# NPC Quest Givers
|
||||
quest_giver_npc_ids:
|
||||
- npc_mayor_aldric
|
||||
quest_giver_name: "Mayor Aldric Thornwood"
|
||||
|
||||
# Location context
|
||||
location_id: crossville_village
|
||||
region_id: crossville
|
||||
|
||||
# Offering conditions
|
||||
offering_triggers:
|
||||
location_types: ["town", "official"]
|
||||
specific_locations: ["crossville_village", "crossville_town_hall"]
|
||||
min_character_level: 3
|
||||
max_character_level: 8
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
town: 0.20
|
||||
official: 0.40
|
||||
|
||||
# NPC-specific offer dialogue
|
||||
npc_offer_dialogues:
|
||||
npc_mayor_aldric:
|
||||
dialogue: |
|
||||
*straightens his chain of office* Crossville faces a grave threat.
|
||||
Bandits have been attacking merchants on the eastern road. Trade
|
||||
is suffering, and I fear for our people's safety. The village
|
||||
will pay handsomely for someone who can... resolve this situation.
|
||||
conditions:
|
||||
min_relationship: 35
|
||||
required_flags: []
|
||||
forbidden_flags: ["failed_bandit_quest"]
|
||||
|
||||
# What NPCs know
|
||||
npc_quest_knowledge:
|
||||
npc_mayor_aldric:
|
||||
- "The bandits have a camp somewhere in the eastern forest"
|
||||
- "They're led by a man called 'Blackhand' - a former soldier"
|
||||
- "At least a dozen bandits, well-armed and organized"
|
||||
- "Three merchant caravans have been hit in the past month"
|
||||
- "The militia is too small to mount an assault"
|
||||
|
||||
# Embedded lore
|
||||
lore_context:
|
||||
backstory: |
|
||||
The bandit leader Blackhand was once a soldier in the King's
|
||||
army. After the war ended, he turned to brigandry rather than
|
||||
return to a life of farming. His military training makes his
|
||||
gang far more dangerous than common thieves.
|
||||
world_connections:
|
||||
- "Many veterans turned to banditry after the war - a kingdom-wide problem"
|
||||
- "The mayor may know more about Blackhand than he lets on"
|
||||
regional_hints:
|
||||
- "The eastern forest road is the main trade route to the capital"
|
||||
- "Without trade, Crossville's economy will suffer greatly"
|
||||
|
||||
# Narrative hooks
|
||||
dialogue_templates:
|
||||
narrative_hooks:
|
||||
- "speaks of the economic impact on the village"
|
||||
- "mentions concerned merchants refusing to travel"
|
||||
- "hints at political pressure from higher authorities"
|
||||
- "shows genuine worry for the common folk"
|
||||
ambient_hints:
|
||||
- "You notice fewer merchants in the market than expected"
|
||||
- "Guards at the village gate look nervous and understaffed"
|
||||
- "Wanted posters for 'Blackhand' are posted on walls"
|
||||
|
||||
# Objectives
|
||||
objectives:
|
||||
- objective_id: find_camp
|
||||
description: "Locate the bandit camp in the eastern forest"
|
||||
objective_type: discover
|
||||
required_progress: 1
|
||||
target_location_id: bandit_camp
|
||||
|
||||
- objective_id: defeat_bandits
|
||||
description: "Defeat the bandits"
|
||||
objective_type: kill
|
||||
required_progress: 8
|
||||
target_enemy_type: bandit
|
||||
|
||||
- objective_id: defeat_blackhand
|
||||
description: "Defeat or capture Blackhand"
|
||||
objective_type: kill
|
||||
required_progress: 1
|
||||
target_enemy_type: blackhand_leader
|
||||
|
||||
# Rewards
|
||||
rewards:
|
||||
gold: 200
|
||||
experience: 350
|
||||
items:
|
||||
- blackhand_sword
|
||||
relationship_bonuses:
|
||||
npc_mayor_aldric: 20
|
||||
unlocks_quests: ["quest_mayors_secret"]
|
||||
reveals_locations: ["bandit_camp"]
|
||||
|
||||
# Completion dialogue
|
||||
completion_dialogue:
|
||||
npc_mayor_aldric: |
|
||||
*visible relief* The bandits are dealt with? This is excellent
|
||||
news. You've done Crossville a great service. *hands over a
|
||||
heavy coin purse* Your reward, as promised. And... I may have
|
||||
other matters that require someone of your capabilities. We
|
||||
should speak again soon.
|
||||
|
||||
tags:
|
||||
- combat
|
||||
- exploration
|
||||
- story
|
||||
143
api/app/data/quests/medium/missing_shipment.yaml
Normal file
143
api/app/data/quests/medium/missing_shipment.yaml
Normal file
@@ -0,0 +1,143 @@
|
||||
# Missing Shipment - Investigation and exploration quest
|
||||
quest_id: quest_missing_shipment
|
||||
name: "The Missing Shipment"
|
||||
description: |
|
||||
A shipment of dwarven goods, including rare ale and tools, never
|
||||
arrived from the mountain passes. The merchant who was transporting
|
||||
it has not been seen in days. Something must have happened on the road.
|
||||
difficulty: medium
|
||||
|
||||
# NPC Quest Givers
|
||||
quest_giver_npc_ids:
|
||||
- npc_grom_ironbeard
|
||||
- npc_blacksmith_hilda
|
||||
quest_giver_name: "Grom Ironbeard"
|
||||
|
||||
# Location context
|
||||
location_id: crossville_tavern
|
||||
region_id: crossville
|
||||
|
||||
# Offering conditions
|
||||
offering_triggers:
|
||||
location_types: ["tavern", "shop"]
|
||||
specific_locations: ["crossville_tavern", "crossville_forge"]
|
||||
min_character_level: 4
|
||||
max_character_level: 10
|
||||
required_quests_completed:
|
||||
- quest_cellar_rats # Must have helped Grom before
|
||||
probability_weights:
|
||||
tavern: 0.30
|
||||
shop: 0.25
|
||||
|
||||
# NPC-specific offer dialogue
|
||||
npc_offer_dialogues:
|
||||
npc_grom_ironbeard:
|
||||
dialogue: |
|
||||
*polishes glass nervously* My cousin Durgin was supposed to
|
||||
arrive three days ago with a shipment from the mountain holds.
|
||||
Dwarven stout, quality ore, fine tools. He's never late.
|
||||
*voice drops* Something's happened on the mountain road.
|
||||
I'm too old to go myself, but I'll pay well for news of him.
|
||||
conditions:
|
||||
min_relationship: 50
|
||||
required_flags: ["completed_cellar_rats"]
|
||||
forbidden_flags: []
|
||||
|
||||
npc_blacksmith_hilda:
|
||||
dialogue: |
|
||||
*frowns at empty shelves* My shipment of dwarven ore never
|
||||
arrived. Durgin Stonefoot was bringing it - he's Grom's cousin.
|
||||
Without that ore, I can't forge anything worth selling.
|
||||
The mountain road can be dangerous... would you look into it?
|
||||
conditions:
|
||||
min_relationship: 40
|
||||
required_flags: []
|
||||
forbidden_flags: []
|
||||
|
||||
# What NPCs know
|
||||
npc_quest_knowledge:
|
||||
npc_grom_ironbeard:
|
||||
- "Durgin always takes the north pass - it's longer but safer"
|
||||
- "He travels with two guards and a pack mule"
|
||||
- "The shipment is worth at least 500 gold"
|
||||
- "There have been rumors of strange creatures in the mountains lately"
|
||||
npc_blacksmith_hilda:
|
||||
- "Durgin is reliable - never missed a delivery in 15 years"
|
||||
- "The ore he brings is from the deep mines - best quality"
|
||||
- "Grom is worried sick about his cousin"
|
||||
|
||||
# Embedded lore
|
||||
lore_context:
|
||||
backstory: |
|
||||
Durgin Stonefoot has been making the mountain crossing for years,
|
||||
bringing goods from the dwarven holds to the human settlements
|
||||
below. He's a seasoned traveler who knows every rock and crevice
|
||||
of the mountain road.
|
||||
world_connections:
|
||||
- "The mountain passes have seen increased monster activity"
|
||||
- "Trade routes between dwarves and humans are vital to both economies"
|
||||
regional_hints:
|
||||
- "The north pass winds through old ruins from the First Age"
|
||||
- "Travelers speak of hearing strange howls at night"
|
||||
|
||||
# Narrative hooks
|
||||
dialogue_templates:
|
||||
narrative_hooks:
|
||||
- "mentions family connection to the missing merchant"
|
||||
- "worries about more than just the lost goods"
|
||||
- "offers personal items as a bonus reward"
|
||||
- "knows something about the mountain dangers"
|
||||
ambient_hints:
|
||||
- "Empty barrels line the cellar where shipments should be"
|
||||
- "Other merchants discuss avoiding the mountain road"
|
||||
|
||||
# Objectives
|
||||
objectives:
|
||||
- objective_id: search_road
|
||||
description: "Search the mountain road for signs of the shipment"
|
||||
objective_type: travel
|
||||
required_progress: 1
|
||||
target_location_id: mountain_pass
|
||||
|
||||
- objective_id: find_durgin
|
||||
description: "Discover what happened to Durgin"
|
||||
objective_type: discover
|
||||
required_progress: 1
|
||||
target_location_id: durgin_campsite
|
||||
|
||||
- objective_id: recover_goods
|
||||
description: "Recover the missing goods (if possible)"
|
||||
objective_type: collect
|
||||
required_progress: 1
|
||||
target_item_id: dwarven_shipment
|
||||
|
||||
# Rewards
|
||||
rewards:
|
||||
gold: 150
|
||||
experience: 300
|
||||
items:
|
||||
- dwarven_ale_keg
|
||||
relationship_bonuses:
|
||||
npc_grom_ironbeard: 15
|
||||
npc_blacksmith_hilda: 10
|
||||
unlocks_quests: ["quest_mountain_menace"]
|
||||
reveals_locations: ["mountain_pass", "durgin_campsite"]
|
||||
|
||||
# Completion dialogue
|
||||
completion_dialogue:
|
||||
npc_grom_ironbeard: |
|
||||
*relief floods his face* Durgin is alive? Thank the ancestors!
|
||||
*takes the recovered goods* And you brought back what you could.
|
||||
You've done more than I could ask. Here's your payment, and...
|
||||
*pulls out a small flask* ...my personal reserve. Drink it in
|
||||
good health, friend.
|
||||
|
||||
npc_blacksmith_hilda: |
|
||||
*examines the recovered ore* This will do nicely. Thank you
|
||||
for finding out what happened. Poor Durgin - I hope he recovers.
|
||||
You've earned both coin and my gratitude.
|
||||
|
||||
tags:
|
||||
- investigation
|
||||
- exploration
|
||||
- story
|
||||
@@ -66,6 +66,8 @@ class Character:
|
||||
|
||||
# Quests and exploration
|
||||
active_quests: List[str] = field(default_factory=list)
|
||||
completed_quests: List[str] = field(default_factory=list)
|
||||
quest_states: Dict[str, Dict] = field(default_factory=dict) # quest_id -> CharacterQuestState.to_dict()
|
||||
discovered_locations: List[str] = field(default_factory=list)
|
||||
current_location: Optional[str] = None # Set to origin starting location on creation
|
||||
|
||||
@@ -378,6 +380,8 @@ class Character:
|
||||
"equipped": {slot: item.to_dict() for slot, item in self.equipped.items()},
|
||||
"gold": self.gold,
|
||||
"active_quests": self.active_quests,
|
||||
"completed_quests": self.completed_quests,
|
||||
"quest_states": self.quest_states,
|
||||
"discovered_locations": self.discovered_locations,
|
||||
"current_location": self.current_location,
|
||||
"npc_interactions": self.npc_interactions,
|
||||
@@ -467,6 +471,8 @@ class Character:
|
||||
equipped=equipped,
|
||||
gold=data.get("gold", 0),
|
||||
active_quests=data.get("active_quests", []),
|
||||
completed_quests=data.get("completed_quests", []),
|
||||
quest_states=data.get("quest_states", {}),
|
||||
discovered_locations=data.get("discovered_locations", []),
|
||||
current_location=data.get("current_location"),
|
||||
npc_interactions=data.get("npc_interactions", {}),
|
||||
|
||||
649
api/app/models/quest.py
Normal file
649
api/app/models/quest.py
Normal file
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
Quest data models for the quest system.
|
||||
|
||||
This module defines Quest and related dataclasses for the YAML-driven quest
|
||||
system. Quests are loaded from YAML files and define their own NPC givers,
|
||||
objectives, rewards, and offering conditions.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ObjectiveType(Enum):
|
||||
"""Types of quest objectives."""
|
||||
KILL = "kill"
|
||||
COLLECT = "collect"
|
||||
TRAVEL = "travel"
|
||||
INTERACT = "interact"
|
||||
DISCOVER = "discover"
|
||||
|
||||
|
||||
class QuestDifficulty(Enum):
|
||||
"""Quest difficulty levels."""
|
||||
EASY = "easy"
|
||||
MEDIUM = "medium"
|
||||
HARD = "hard"
|
||||
EPIC = "epic"
|
||||
|
||||
|
||||
class QuestStatus(Enum):
|
||||
"""Status of a quest for a character."""
|
||||
AVAILABLE = "available"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestObjective:
|
||||
"""
|
||||
A single objective within a quest.
|
||||
|
||||
Objectives track player progress toward quest completion. Each objective
|
||||
has a type (kill, collect, etc.) and a required progress amount.
|
||||
|
||||
Attributes:
|
||||
objective_id: Unique identifier within the quest
|
||||
description: Player-facing description (e.g., "Kill 10 giant rats")
|
||||
objective_type: Type of objective (kill, collect, travel, etc.)
|
||||
required_progress: Target amount to complete (e.g., 10 for 10 kills)
|
||||
current_progress: Current progress (tracked on character's quest state)
|
||||
target_enemy_type: For kill objectives - enemy type to kill
|
||||
target_item_id: For collect objectives - item to collect
|
||||
target_location_id: For travel/discover objectives - location to reach
|
||||
target_npc_id: For interact objectives - NPC to interact with
|
||||
"""
|
||||
|
||||
objective_id: str
|
||||
description: str
|
||||
objective_type: ObjectiveType
|
||||
required_progress: int = 1
|
||||
current_progress: int = 0
|
||||
target_enemy_type: Optional[str] = None
|
||||
target_item_id: Optional[str] = None
|
||||
target_location_id: Optional[str] = None
|
||||
target_npc_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
"""Check if this objective is complete."""
|
||||
return self.current_progress >= self.required_progress
|
||||
|
||||
@property
|
||||
def progress_text(self) -> str:
|
||||
"""Get formatted progress text (e.g., '5/10')."""
|
||||
return f"{self.current_progress}/{self.required_progress}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize objective to dictionary."""
|
||||
return {
|
||||
"objective_id": self.objective_id,
|
||||
"description": self.description,
|
||||
"objective_type": self.objective_type.value,
|
||||
"required_progress": self.required_progress,
|
||||
"current_progress": self.current_progress,
|
||||
"target_enemy_type": self.target_enemy_type,
|
||||
"target_item_id": self.target_item_id,
|
||||
"target_location_id": self.target_location_id,
|
||||
"target_npc_id": self.target_npc_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective':
|
||||
"""Deserialize objective from dictionary."""
|
||||
return cls(
|
||||
objective_id=data["objective_id"],
|
||||
description=data["description"],
|
||||
objective_type=ObjectiveType(data["objective_type"]),
|
||||
required_progress=data.get("required_progress", 1),
|
||||
current_progress=data.get("current_progress", 0),
|
||||
target_enemy_type=data.get("target_enemy_type"),
|
||||
target_item_id=data.get("target_item_id"),
|
||||
target_location_id=data.get("target_location_id"),
|
||||
target_npc_id=data.get("target_npc_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestReward:
|
||||
"""
|
||||
Rewards granted upon quest completion.
|
||||
|
||||
Attributes:
|
||||
gold: Gold amount
|
||||
experience: XP amount
|
||||
items: List of item IDs to grant
|
||||
relationship_bonuses: NPC relationship increases {npc_id: amount}
|
||||
unlocks_quests: Quest IDs that become available after completion
|
||||
reveals_locations: Location IDs revealed upon completion
|
||||
"""
|
||||
|
||||
gold: int = 0
|
||||
experience: int = 0
|
||||
items: List[str] = field(default_factory=list)
|
||||
relationship_bonuses: Dict[str, int] = field(default_factory=dict)
|
||||
unlocks_quests: List[str] = field(default_factory=list)
|
||||
reveals_locations: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize rewards to dictionary."""
|
||||
return {
|
||||
"gold": self.gold,
|
||||
"experience": self.experience,
|
||||
"items": self.items,
|
||||
"relationship_bonuses": self.relationship_bonuses,
|
||||
"unlocks_quests": self.unlocks_quests,
|
||||
"reveals_locations": self.reveals_locations,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward':
|
||||
"""Deserialize rewards from dictionary."""
|
||||
return cls(
|
||||
gold=data.get("gold", 0),
|
||||
experience=data.get("experience", 0),
|
||||
items=data.get("items", []),
|
||||
relationship_bonuses=data.get("relationship_bonuses", {}),
|
||||
unlocks_quests=data.get("unlocks_quests", []),
|
||||
reveals_locations=data.get("reveals_locations", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestOfferConditions:
|
||||
"""
|
||||
Conditions required for an NPC to offer this quest.
|
||||
|
||||
These are checked per-NPC to determine if they can offer the quest
|
||||
to a specific character based on relationship and flags.
|
||||
|
||||
Attributes:
|
||||
min_relationship: Minimum relationship level required (0-100)
|
||||
required_flags: Custom flags that must be set on character's NPC interaction
|
||||
forbidden_flags: Custom flags that must NOT be set (e.g., "refused_this_quest")
|
||||
"""
|
||||
|
||||
min_relationship: int = 0
|
||||
required_flags: List[str] = field(default_factory=list)
|
||||
forbidden_flags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize conditions to dictionary."""
|
||||
return {
|
||||
"min_relationship": self.min_relationship,
|
||||
"required_flags": self.required_flags,
|
||||
"forbidden_flags": self.forbidden_flags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferConditions':
|
||||
"""Deserialize conditions from dictionary."""
|
||||
return cls(
|
||||
min_relationship=data.get("min_relationship", 0),
|
||||
required_flags=data.get("required_flags", []),
|
||||
forbidden_flags=data.get("forbidden_flags", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCOfferDialogue:
|
||||
"""
|
||||
NPC-specific dialogue for offering a quest.
|
||||
|
||||
Each NPC can have custom dialogue and conditions for offering a quest,
|
||||
allowing different NPCs to present the same quest differently.
|
||||
|
||||
Attributes:
|
||||
dialogue: The custom offer dialogue for this NPC
|
||||
conditions: Conditions for this NPC to offer the quest
|
||||
"""
|
||||
|
||||
dialogue: str
|
||||
conditions: QuestOfferConditions = field(default_factory=QuestOfferConditions)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"dialogue": self.dialogue,
|
||||
"conditions": self.conditions.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCOfferDialogue':
|
||||
"""Deserialize from dictionary."""
|
||||
conditions = QuestOfferConditions()
|
||||
if data.get("conditions"):
|
||||
conditions = QuestOfferConditions.from_dict(data["conditions"])
|
||||
return cls(
|
||||
dialogue=data.get("dialogue", ""),
|
||||
conditions=conditions,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestOfferingTriggers:
|
||||
"""
|
||||
Conditions for when a quest can be offered.
|
||||
|
||||
Controls the probability and eligibility for quest offering based on
|
||||
location, character level, and prerequisite quests.
|
||||
|
||||
Attributes:
|
||||
location_types: Location types where quest can be offered (e.g., ["tavern", "town"])
|
||||
specific_locations: Specific location IDs (if more restrictive than types)
|
||||
min_character_level: Minimum character level to receive quest
|
||||
max_character_level: Maximum character level (optional upper bound)
|
||||
required_quests_completed: Quest IDs that must be completed first
|
||||
probability_weights: Probability by location type (e.g., {"tavern": 0.35})
|
||||
"""
|
||||
|
||||
location_types: List[str] = field(default_factory=list)
|
||||
specific_locations: List[str] = field(default_factory=list)
|
||||
min_character_level: int = 1
|
||||
max_character_level: int = 100
|
||||
required_quests_completed: List[str] = field(default_factory=list)
|
||||
probability_weights: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def get_probability(self, location_type: str) -> float:
|
||||
"""
|
||||
Get the offering probability for a location type.
|
||||
|
||||
Args:
|
||||
location_type: Type of location (e.g., "tavern", "town", "wilderness")
|
||||
|
||||
Returns:
|
||||
Probability between 0.0 and 1.0
|
||||
"""
|
||||
return self.probability_weights.get(location_type, 0.0)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize triggers to dictionary."""
|
||||
return {
|
||||
"location_types": self.location_types,
|
||||
"specific_locations": self.specific_locations,
|
||||
"min_character_level": self.min_character_level,
|
||||
"max_character_level": self.max_character_level,
|
||||
"required_quests_completed": self.required_quests_completed,
|
||||
"probability_weights": self.probability_weights,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferingTriggers':
|
||||
"""Deserialize triggers from dictionary."""
|
||||
return cls(
|
||||
location_types=data.get("location_types", []),
|
||||
specific_locations=data.get("specific_locations", []),
|
||||
min_character_level=data.get("min_character_level", 1),
|
||||
max_character_level=data.get("max_character_level", 100),
|
||||
required_quests_completed=data.get("required_quests_completed", []),
|
||||
probability_weights=data.get("probability_weights", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestDialogueTemplates:
|
||||
"""
|
||||
Dialogue templates for natural quest offering.
|
||||
|
||||
Provides narrative hooks and hints that the AI can use to naturally
|
||||
weave quest offers into conversation.
|
||||
|
||||
Attributes:
|
||||
narrative_hooks: Phrases the NPC might mention (e.g., "mentions scratching sounds")
|
||||
ambient_hints: Environmental descriptions (e.g., "You notice scratch marks")
|
||||
"""
|
||||
|
||||
narrative_hooks: List[str] = field(default_factory=list)
|
||||
ambient_hints: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"narrative_hooks": self.narrative_hooks,
|
||||
"ambient_hints": self.ambient_hints,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestDialogueTemplates':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
narrative_hooks=data.get("narrative_hooks", []),
|
||||
ambient_hints=data.get("ambient_hints", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestLoreContext:
|
||||
"""
|
||||
Embedded lore context for quest offering.
|
||||
|
||||
Provides backstory and world context that the AI can reference when
|
||||
discussing the quest with players. This serves as a stub until the
|
||||
Weaviate vector database is implemented in Phase 6.
|
||||
|
||||
Attributes:
|
||||
backstory: Background story for this quest
|
||||
world_connections: How this quest connects to world events
|
||||
regional_hints: Local/regional information related to the quest
|
||||
"""
|
||||
|
||||
backstory: str = ""
|
||||
world_connections: List[str] = field(default_factory=list)
|
||||
regional_hints: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"backstory": self.backstory,
|
||||
"world_connections": self.world_connections,
|
||||
"regional_hints": self.regional_hints,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'QuestLoreContext':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
backstory=data.get("backstory", ""),
|
||||
world_connections=data.get("world_connections", []),
|
||||
regional_hints=data.get("regional_hints", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Quest:
|
||||
"""
|
||||
Complete quest definition loaded from YAML.
|
||||
|
||||
Quests are the central data structure for the quest system. They define
|
||||
their own NPC givers (quest-centric design), objectives, rewards, and
|
||||
offering conditions.
|
||||
|
||||
Attributes:
|
||||
quest_id: Unique identifier (e.g., "quest_cellar_rats")
|
||||
name: Display name (e.g., "Rat Problem in the Cellar")
|
||||
description: Full quest description
|
||||
difficulty: Quest difficulty level
|
||||
quest_giver_npc_ids: NPCs who can offer this quest
|
||||
quest_giver_name: Display name fallback for quest giver
|
||||
location_id: Primary location for this quest
|
||||
region_id: Region this quest belongs to
|
||||
objectives: List of objectives to complete
|
||||
rewards: Rewards for completion
|
||||
offering_triggers: When/where quest can be offered
|
||||
npc_offer_dialogues: NPC-specific offer dialogue {npc_id: NPCOfferDialogue}
|
||||
npc_quest_knowledge: What NPCs know about this quest {npc_id: [facts]}
|
||||
lore_context: Embedded lore for AI context
|
||||
dialogue_templates: Narrative hooks for natural offering
|
||||
completion_dialogue: NPC dialogue upon completion {npc_id: dialogue}
|
||||
tags: Metadata tags for filtering
|
||||
"""
|
||||
|
||||
quest_id: str
|
||||
name: str
|
||||
description: str
|
||||
difficulty: QuestDifficulty
|
||||
quest_giver_npc_ids: List[str]
|
||||
quest_giver_name: str = ""
|
||||
location_id: str = ""
|
||||
region_id: str = ""
|
||||
objectives: List[QuestObjective] = field(default_factory=list)
|
||||
rewards: QuestReward = field(default_factory=QuestReward)
|
||||
offering_triggers: QuestOfferingTriggers = field(default_factory=QuestOfferingTriggers)
|
||||
npc_offer_dialogues: Dict[str, NPCOfferDialogue] = field(default_factory=dict)
|
||||
npc_quest_knowledge: Dict[str, List[str]] = field(default_factory=dict)
|
||||
lore_context: QuestLoreContext = field(default_factory=QuestLoreContext)
|
||||
dialogue_templates: QuestDialogueTemplates = field(default_factory=QuestDialogueTemplates)
|
||||
completion_dialogue: Dict[str, str] = field(default_factory=dict)
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
"""Check if all objectives are complete."""
|
||||
return all(obj.is_complete for obj in self.objectives)
|
||||
|
||||
def get_offer_dialogue(self, npc_id: str) -> str:
|
||||
"""
|
||||
Get the offer dialogue for a specific NPC.
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
|
||||
Returns:
|
||||
Custom dialogue if defined, otherwise empty string
|
||||
"""
|
||||
if npc_id in self.npc_offer_dialogues:
|
||||
return self.npc_offer_dialogues[npc_id].dialogue
|
||||
return ""
|
||||
|
||||
def get_offer_conditions(self, npc_id: str) -> QuestOfferConditions:
|
||||
"""
|
||||
Get the offer conditions for a specific NPC.
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
|
||||
Returns:
|
||||
Conditions if defined, otherwise default conditions
|
||||
"""
|
||||
if npc_id in self.npc_offer_dialogues:
|
||||
return self.npc_offer_dialogues[npc_id].conditions
|
||||
return QuestOfferConditions()
|
||||
|
||||
def get_npc_knowledge(self, npc_id: str) -> List[str]:
|
||||
"""
|
||||
Get what an NPC knows about this quest.
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
|
||||
Returns:
|
||||
List of facts the NPC knows about the quest
|
||||
"""
|
||||
return self.npc_quest_knowledge.get(npc_id, [])
|
||||
|
||||
def get_completion_dialogue(self, npc_id: str) -> str:
|
||||
"""
|
||||
Get the completion dialogue for a specific NPC.
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
|
||||
Returns:
|
||||
Custom completion dialogue if defined, otherwise empty string
|
||||
"""
|
||||
return self.completion_dialogue.get(npc_id, "")
|
||||
|
||||
def can_npc_offer(self, npc_id: str) -> bool:
|
||||
"""
|
||||
Check if an NPC can offer this quest.
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
|
||||
Returns:
|
||||
True if NPC is in quest_giver_npc_ids
|
||||
"""
|
||||
return npc_id in self.quest_giver_npc_ids
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize quest to dictionary."""
|
||||
return {
|
||||
"quest_id": self.quest_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"difficulty": self.difficulty.value,
|
||||
"quest_giver_npc_ids": self.quest_giver_npc_ids,
|
||||
"quest_giver_name": self.quest_giver_name,
|
||||
"location_id": self.location_id,
|
||||
"region_id": self.region_id,
|
||||
"objectives": [obj.to_dict() for obj in self.objectives],
|
||||
"rewards": self.rewards.to_dict(),
|
||||
"offering_triggers": self.offering_triggers.to_dict(),
|
||||
"npc_offer_dialogues": {
|
||||
npc_id: dialogue.to_dict()
|
||||
for npc_id, dialogue in self.npc_offer_dialogues.items()
|
||||
},
|
||||
"npc_quest_knowledge": self.npc_quest_knowledge,
|
||||
"lore_context": self.lore_context.to_dict(),
|
||||
"dialogue_templates": self.dialogue_templates.to_dict(),
|
||||
"completion_dialogue": self.completion_dialogue,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
def to_offer_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize quest for offering UI display.
|
||||
|
||||
Returns a trimmed version suitable for displaying to players
|
||||
when a quest is being offered.
|
||||
|
||||
Returns:
|
||||
Dictionary with quest offer display data
|
||||
"""
|
||||
return {
|
||||
"quest_id": self.quest_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"difficulty": self.difficulty.value,
|
||||
"quest_giver_name": self.quest_giver_name,
|
||||
"objectives": [
|
||||
{"description": obj.description, "progress_text": obj.progress_text}
|
||||
for obj in self.objectives
|
||||
],
|
||||
"rewards": {
|
||||
"gold": self.rewards.gold,
|
||||
"experience": self.rewards.experience,
|
||||
"items": self.rewards.items,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Quest':
|
||||
"""Deserialize quest from dictionary."""
|
||||
# Parse objectives
|
||||
objectives = [
|
||||
QuestObjective.from_dict(obj)
|
||||
for obj in data.get("objectives", [])
|
||||
]
|
||||
|
||||
# Parse rewards
|
||||
rewards = QuestReward()
|
||||
if data.get("rewards"):
|
||||
rewards = QuestReward.from_dict(data["rewards"])
|
||||
|
||||
# Parse offering triggers
|
||||
offering_triggers = QuestOfferingTriggers()
|
||||
if data.get("offering_triggers"):
|
||||
offering_triggers = QuestOfferingTriggers.from_dict(data["offering_triggers"])
|
||||
|
||||
# Parse NPC offer dialogues
|
||||
npc_offer_dialogues = {}
|
||||
for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items():
|
||||
npc_offer_dialogues[npc_id] = NPCOfferDialogue.from_dict(dialogue_data)
|
||||
|
||||
# Parse lore context
|
||||
lore_context = QuestLoreContext()
|
||||
if data.get("lore_context"):
|
||||
lore_context = QuestLoreContext.from_dict(data["lore_context"])
|
||||
|
||||
# Parse dialogue templates
|
||||
dialogue_templates = QuestDialogueTemplates()
|
||||
if data.get("dialogue_templates"):
|
||||
dialogue_templates = QuestDialogueTemplates.from_dict(data["dialogue_templates"])
|
||||
|
||||
return cls(
|
||||
quest_id=data["quest_id"],
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
difficulty=QuestDifficulty(data.get("difficulty", "easy")),
|
||||
quest_giver_npc_ids=data.get("quest_giver_npc_ids", []),
|
||||
quest_giver_name=data.get("quest_giver_name", ""),
|
||||
location_id=data.get("location_id", ""),
|
||||
region_id=data.get("region_id", ""),
|
||||
objectives=objectives,
|
||||
rewards=rewards,
|
||||
offering_triggers=offering_triggers,
|
||||
npc_offer_dialogues=npc_offer_dialogues,
|
||||
npc_quest_knowledge=data.get("npc_quest_knowledge", {}),
|
||||
lore_context=lore_context,
|
||||
dialogue_templates=dialogue_templates,
|
||||
completion_dialogue=data.get("completion_dialogue", {}),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the quest."""
|
||||
return f"Quest({self.quest_id}, {self.name}, {self.difficulty.value})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CharacterQuestState:
|
||||
"""
|
||||
Tracks a character's progress on an active quest.
|
||||
|
||||
Stored in character's quest tracking data to persist progress
|
||||
across sessions.
|
||||
|
||||
Attributes:
|
||||
quest_id: The quest being tracked
|
||||
status: Current quest status
|
||||
accepted_at: ISO timestamp when quest was accepted
|
||||
objectives_progress: Progress on each objective {objective_id: current_progress}
|
||||
completed_at: ISO timestamp when quest was completed (if completed)
|
||||
"""
|
||||
|
||||
quest_id: str
|
||||
status: QuestStatus = QuestStatus.ACTIVE
|
||||
accepted_at: str = ""
|
||||
objectives_progress: Dict[str, int] = field(default_factory=dict)
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
def update_progress(self, objective_id: str, amount: int = 1) -> None:
|
||||
"""
|
||||
Update progress on an objective.
|
||||
|
||||
Args:
|
||||
objective_id: The objective to update
|
||||
amount: Amount to add to progress
|
||||
"""
|
||||
current = self.objectives_progress.get(objective_id, 0)
|
||||
self.objectives_progress[objective_id] = current + amount
|
||||
|
||||
def get_progress(self, objective_id: str) -> int:
|
||||
"""
|
||||
Get current progress on an objective.
|
||||
|
||||
Args:
|
||||
objective_id: The objective to check
|
||||
|
||||
Returns:
|
||||
Current progress amount
|
||||
"""
|
||||
return self.objectives_progress.get(objective_id, 0)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"quest_id": self.quest_id,
|
||||
"status": self.status.value,
|
||||
"accepted_at": self.accepted_at,
|
||||
"objectives_progress": self.objectives_progress,
|
||||
"completed_at": self.completed_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'CharacterQuestState':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
quest_id=data["quest_id"],
|
||||
status=QuestStatus(data.get("status", "active")),
|
||||
accepted_at=data.get("accepted_at", ""),
|
||||
objectives_progress=data.get("objectives_progress", {}),
|
||||
completed_at=data.get("completed_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation."""
|
||||
return f"CharacterQuestState({self.quest_id}, {self.status.value})"
|
||||
@@ -34,6 +34,8 @@ from app.services.combat_repository import (
|
||||
get_combat_repository,
|
||||
CombatRepository
|
||||
)
|
||||
from app.services.quest_service import get_quest_service
|
||||
from app.models.quest import ObjectiveType
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -1290,6 +1292,9 @@ class CombatService:
|
||||
items=len(rewards.items),
|
||||
level_ups=rewards.level_ups)
|
||||
|
||||
# Update quest progress for kill objectives
|
||||
self._update_quest_kill_progress(encounter, session, user_id)
|
||||
|
||||
return rewards
|
||||
|
||||
def _build_loot_context(self, encounter: CombatEncounter) -> LootContext:
|
||||
@@ -1339,6 +1344,114 @@ class CombatService:
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
|
||||
def _update_quest_kill_progress(
|
||||
self,
|
||||
encounter: CombatEncounter,
|
||||
session,
|
||||
user_id: str
|
||||
) -> None:
|
||||
"""
|
||||
Update quest progress for kill objectives based on defeated enemies.
|
||||
|
||||
Scans all defeated enemies in the encounter, identifies their types,
|
||||
and updates any active quests that have kill objectives matching
|
||||
those enemy types.
|
||||
|
||||
Args:
|
||||
encounter: Completed combat encounter
|
||||
session: Game session
|
||||
user_id: User ID for character updates
|
||||
"""
|
||||
# Collect killed enemy types and counts
|
||||
killed_enemy_types: Dict[str, int] = {}
|
||||
for combatant in encounter.combatants:
|
||||
if not combatant.is_player and combatant.is_dead():
|
||||
enemy_id = combatant.combatant_id.split("_")[0]
|
||||
enemy = self.enemy_loader.load_enemy(enemy_id)
|
||||
if enemy:
|
||||
# Use enemy_id as the type identifier (matches target_enemy_type in quests)
|
||||
killed_enemy_types[enemy_id] = killed_enemy_types.get(enemy_id, 0) + 1
|
||||
|
||||
if not killed_enemy_types:
|
||||
return # No enemies killed
|
||||
|
||||
# Get character
|
||||
if session.is_solo():
|
||||
char_id = session.solo_character_id
|
||||
else:
|
||||
# For multiplayer, we'd need to update all players
|
||||
# For now, just handle solo
|
||||
return
|
||||
|
||||
try:
|
||||
character = self.character_service.get_character(char_id, user_id)
|
||||
if not character:
|
||||
return
|
||||
|
||||
# Get active quests
|
||||
active_quests = getattr(character, 'active_quests', [])
|
||||
if not active_quests:
|
||||
return
|
||||
|
||||
quest_service = get_quest_service()
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
updated = False
|
||||
|
||||
for quest_id in active_quests:
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
if not quest:
|
||||
continue
|
||||
|
||||
quest_state = quest_states.get(quest_id, {})
|
||||
objectives_progress = quest_state.get('objectives_progress', {})
|
||||
|
||||
for objective in quest.objectives:
|
||||
# Check if this is a kill objective with a matching enemy type
|
||||
if objective.objective_type != ObjectiveType.KILL:
|
||||
continue
|
||||
|
||||
target_enemy = objective.target_enemy_type
|
||||
if not target_enemy or target_enemy not in killed_enemy_types:
|
||||
continue
|
||||
|
||||
# Update progress for this objective
|
||||
kill_count = killed_enemy_types[target_enemy]
|
||||
current_progress = objectives_progress.get(objective.objective_id, 0)
|
||||
new_progress = min(
|
||||
current_progress + kill_count,
|
||||
objective.required_progress
|
||||
)
|
||||
|
||||
if new_progress > current_progress:
|
||||
objectives_progress[objective.objective_id] = new_progress
|
||||
updated = True
|
||||
|
||||
logger.info(
|
||||
"Quest kill progress updated",
|
||||
character_id=char_id,
|
||||
quest_id=quest_id,
|
||||
objective_id=objective.objective_id,
|
||||
enemy_type=target_enemy,
|
||||
kills=kill_count,
|
||||
progress=f"{new_progress}/{objective.required_progress}"
|
||||
)
|
||||
|
||||
# Update quest state
|
||||
if quest_id in quest_states:
|
||||
quest_states[quest_id]['objectives_progress'] = objectives_progress
|
||||
|
||||
# Save character if any quests were updated
|
||||
if updated:
|
||||
character.quest_states = quest_states
|
||||
self.character_service.update_character(character, user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update quest kill progress",
|
||||
char_id=char_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def _create_combatant_from_character(
|
||||
self,
|
||||
character: Character
|
||||
|
||||
374
api/app/services/lore_service.py
Normal file
374
api/app/services/lore_service.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
LoreService for retrieving contextual lore for NPC conversations.
|
||||
|
||||
This module provides an interface for lore retrieval that will be
|
||||
implemented with Weaviate in Phase 6. For now, it provides a mock
|
||||
implementation that returns embedded lore from quest definitions.
|
||||
|
||||
The service follows a three-tier knowledge hierarchy:
|
||||
1. World Lore - Global history, mythology, kingdoms
|
||||
2. Regional Lore - Local history, landmarks, rumors
|
||||
3. NPC Persona - Individual NPC knowledge (already in NPC YAML)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional, Protocol
|
||||
import structlog
|
||||
|
||||
from app.models.quest import Quest, QuestLoreContext
|
||||
from app.models.npc import NPC
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoreEntry:
|
||||
"""
|
||||
A single piece of lore information.
|
||||
|
||||
Attributes:
|
||||
content: The lore text content
|
||||
title: Optional title for the lore entry
|
||||
knowledge_type: Type of knowledge (common, academic, secret)
|
||||
source: Where this lore came from (world, regional, quest)
|
||||
metadata: Additional metadata for filtering
|
||||
"""
|
||||
|
||||
content: str
|
||||
title: str = ""
|
||||
knowledge_type: str = "common"
|
||||
source: str = "quest"
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"content": self.content,
|
||||
"title": self.title,
|
||||
"knowledge_type": self.knowledge_type,
|
||||
"source": self.source,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoreContext:
|
||||
"""
|
||||
Aggregated lore context for AI prompts.
|
||||
|
||||
Contains lore from multiple sources, filtered for the current
|
||||
NPC and conversation context.
|
||||
|
||||
Attributes:
|
||||
world_lore: Global world knowledge entries
|
||||
regional_lore: Local/regional knowledge entries
|
||||
quest_lore: Quest-specific lore entries
|
||||
"""
|
||||
|
||||
world_lore: List[LoreEntry] = field(default_factory=list)
|
||||
regional_lore: List[LoreEntry] = field(default_factory=list)
|
||||
quest_lore: List[LoreEntry] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary for AI prompt injection."""
|
||||
return {
|
||||
"world": [entry.to_dict() for entry in self.world_lore],
|
||||
"regional": [entry.to_dict() for entry in self.regional_lore],
|
||||
"quest": [entry.to_dict() for entry in self.quest_lore],
|
||||
}
|
||||
|
||||
def has_content(self) -> bool:
|
||||
"""Check if any lore content is available."""
|
||||
return bool(self.world_lore or self.regional_lore or self.quest_lore)
|
||||
|
||||
|
||||
class LoreServiceInterface(ABC):
|
||||
"""
|
||||
Abstract interface for lore services.
|
||||
|
||||
This interface allows swapping between MockLoreService (current)
|
||||
and WeaviateLoreService (Phase 6) without changing calling code.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_lore_context(
|
||||
self,
|
||||
npc: NPC,
|
||||
quest: Optional[Quest] = None,
|
||||
topic: str = "",
|
||||
region_id: str = "",
|
||||
) -> LoreContext:
|
||||
"""
|
||||
Get lore context for an NPC conversation.
|
||||
|
||||
Args:
|
||||
npc: The NPC in the conversation
|
||||
quest: Optional quest being discussed
|
||||
topic: Topic of conversation for semantic search
|
||||
region_id: Region to filter lore by
|
||||
|
||||
Returns:
|
||||
LoreContext with relevant lore entries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def filter_lore_for_npc(
|
||||
self,
|
||||
lore_entries: List[LoreEntry],
|
||||
npc: NPC,
|
||||
) -> List[LoreEntry]:
|
||||
"""
|
||||
Filter lore entries based on what an NPC would know.
|
||||
|
||||
Args:
|
||||
lore_entries: Raw lore entries
|
||||
npc: The NPC to filter for
|
||||
|
||||
Returns:
|
||||
Filtered list of lore entries
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MockLoreService(LoreServiceInterface):
|
||||
"""
|
||||
Mock implementation of LoreService using embedded quest lore.
|
||||
|
||||
This implementation returns lore directly from quest YAML files
|
||||
until Weaviate is implemented in Phase 6. It provides a working
|
||||
lore system without external dependencies.
|
||||
"""
|
||||
|
||||
# NPC roles that have access to academic knowledge
|
||||
ACADEMIC_ROLES = ["scholar", "wizard", "sage", "librarian", "priest", "mage"]
|
||||
|
||||
# NPC roles that might know secrets
|
||||
SECRET_KEEPER_ROLES = ["mayor", "noble", "spy", "elder", "priest"]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the mock lore service."""
|
||||
logger.info("MockLoreService initialized")
|
||||
|
||||
def get_lore_context(
|
||||
self,
|
||||
npc: NPC,
|
||||
quest: Optional[Quest] = None,
|
||||
topic: str = "",
|
||||
region_id: str = "",
|
||||
) -> LoreContext:
|
||||
"""
|
||||
Get lore context for an NPC conversation.
|
||||
|
||||
For the mock implementation, this primarily returns quest
|
||||
embedded lore and some static regional/world hints.
|
||||
|
||||
Args:
|
||||
npc: The NPC in the conversation
|
||||
quest: Optional quest being discussed
|
||||
topic: Topic of conversation (unused in mock)
|
||||
region_id: Region to filter by
|
||||
|
||||
Returns:
|
||||
LoreContext with available lore
|
||||
"""
|
||||
context = LoreContext()
|
||||
|
||||
# Add quest-specific lore if a quest is provided
|
||||
if quest and quest.lore_context:
|
||||
quest_entries = self._extract_quest_lore(quest)
|
||||
context.quest_lore = self.filter_lore_for_npc(quest_entries, npc)
|
||||
|
||||
# Add some mock regional lore based on NPC location
|
||||
regional_entries = self._get_mock_regional_lore(npc.location_id, region_id)
|
||||
context.regional_lore = self.filter_lore_for_npc(regional_entries, npc)
|
||||
|
||||
# Add world lore if NPC is knowledgeable
|
||||
if npc.role in self.ACADEMIC_ROLES:
|
||||
world_entries = self._get_mock_world_lore()
|
||||
context.world_lore = self.filter_lore_for_npc(world_entries, npc)
|
||||
|
||||
logger.debug(
|
||||
"Lore context built",
|
||||
npc_id=npc.npc_id,
|
||||
quest_lore_count=len(context.quest_lore),
|
||||
regional_lore_count=len(context.regional_lore),
|
||||
world_lore_count=len(context.world_lore),
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def filter_lore_for_npc(
|
||||
self,
|
||||
lore_entries: List[LoreEntry],
|
||||
npc: NPC,
|
||||
) -> List[LoreEntry]:
|
||||
"""
|
||||
Filter lore entries based on what an NPC would know.
|
||||
|
||||
Args:
|
||||
lore_entries: Raw lore entries
|
||||
npc: The NPC to filter for
|
||||
|
||||
Returns:
|
||||
Filtered list of lore entries
|
||||
"""
|
||||
filtered = []
|
||||
|
||||
for entry in lore_entries:
|
||||
knowledge_type = entry.knowledge_type
|
||||
|
||||
# Academic knowledge requires appropriate role
|
||||
if knowledge_type == "academic":
|
||||
if npc.role not in self.ACADEMIC_ROLES:
|
||||
continue
|
||||
|
||||
# Secret knowledge requires special role or tag
|
||||
if knowledge_type == "secret":
|
||||
if npc.role not in self.SECRET_KEEPER_ROLES:
|
||||
if "secret_keeper" not in npc.tags:
|
||||
continue
|
||||
|
||||
filtered.append(entry)
|
||||
|
||||
return filtered
|
||||
|
||||
def _extract_quest_lore(self, quest: Quest) -> List[LoreEntry]:
|
||||
"""
|
||||
Extract lore entries from a quest's embedded lore context.
|
||||
|
||||
Args:
|
||||
quest: The quest to extract lore from
|
||||
|
||||
Returns:
|
||||
List of LoreEntry objects
|
||||
"""
|
||||
entries = []
|
||||
lore = quest.lore_context
|
||||
|
||||
# Add backstory as a lore entry
|
||||
if lore.backstory:
|
||||
entries.append(LoreEntry(
|
||||
content=lore.backstory,
|
||||
title=f"Background: {quest.name}",
|
||||
knowledge_type="common",
|
||||
source="quest",
|
||||
metadata={"quest_id": quest.quest_id},
|
||||
))
|
||||
|
||||
# Add world connections
|
||||
for connection in lore.world_connections:
|
||||
entries.append(LoreEntry(
|
||||
content=connection,
|
||||
title="World Connection",
|
||||
knowledge_type="common",
|
||||
source="quest",
|
||||
metadata={"quest_id": quest.quest_id},
|
||||
))
|
||||
|
||||
# Add regional hints
|
||||
for hint in lore.regional_hints:
|
||||
entries.append(LoreEntry(
|
||||
content=hint,
|
||||
title="Local Knowledge",
|
||||
knowledge_type="common",
|
||||
source="quest",
|
||||
metadata={"quest_id": quest.quest_id},
|
||||
))
|
||||
|
||||
return entries
|
||||
|
||||
def _get_mock_regional_lore(
|
||||
self,
|
||||
location_id: str,
|
||||
region_id: str,
|
||||
) -> List[LoreEntry]:
|
||||
"""
|
||||
Get mock regional lore for a location.
|
||||
|
||||
This provides basic regional context until Weaviate is implemented.
|
||||
|
||||
Args:
|
||||
location_id: Specific location
|
||||
region_id: Region identifier
|
||||
|
||||
Returns:
|
||||
List of LoreEntry objects
|
||||
"""
|
||||
entries = []
|
||||
|
||||
# Crossville regional lore
|
||||
if "crossville" in location_id.lower() or region_id == "crossville":
|
||||
entries.extend([
|
||||
LoreEntry(
|
||||
content="Crossville was founded two hundred years ago as a trading post.",
|
||||
title="Crossville History",
|
||||
knowledge_type="common",
|
||||
source="regional",
|
||||
),
|
||||
LoreEntry(
|
||||
content="The Old Mines were sealed fifty years ago after a tragic accident.",
|
||||
title="The Old Mines",
|
||||
knowledge_type="common",
|
||||
source="regional",
|
||||
),
|
||||
LoreEntry(
|
||||
content="The Thornwood family has led the village for three generations.",
|
||||
title="Village Leadership",
|
||||
knowledge_type="common",
|
||||
source="regional",
|
||||
),
|
||||
])
|
||||
|
||||
return entries
|
||||
|
||||
def _get_mock_world_lore(self) -> List[LoreEntry]:
|
||||
"""
|
||||
Get mock world-level lore.
|
||||
|
||||
This provides basic world context until Weaviate is implemented.
|
||||
|
||||
Returns:
|
||||
List of LoreEntry objects
|
||||
"""
|
||||
return [
|
||||
LoreEntry(
|
||||
content="The Five Kingdoms united against the Shadow Empire two hundred years ago in a great war.",
|
||||
title="The Great War",
|
||||
knowledge_type="academic",
|
||||
source="world",
|
||||
),
|
||||
LoreEntry(
|
||||
content="Magic flows from the ley lines that crisscross the land, concentrated at ancient sites.",
|
||||
title="The Nature of Magic",
|
||||
knowledge_type="academic",
|
||||
source="world",
|
||||
),
|
||||
LoreEntry(
|
||||
content="The ancient ruins predate human settlement and are believed to be from the First Age.",
|
||||
title="First Age Ruins",
|
||||
knowledge_type="academic",
|
||||
source="world",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_service_instance: Optional[LoreServiceInterface] = None
|
||||
|
||||
|
||||
def get_lore_service() -> LoreServiceInterface:
|
||||
"""
|
||||
Get the global LoreService instance.
|
||||
|
||||
Currently returns MockLoreService. In Phase 6, this will
|
||||
be updated to return WeaviateLoreService.
|
||||
|
||||
Returns:
|
||||
Singleton LoreService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = MockLoreService()
|
||||
return _service_instance
|
||||
408
api/app/services/quest_eligibility_service.py
Normal file
408
api/app/services/quest_eligibility_service.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
QuestEligibilityService for determining quest offering eligibility.
|
||||
|
||||
This service handles the core logic for determining which quests an NPC
|
||||
can offer to a specific character, including:
|
||||
- Finding quests where the NPC is a quest giver
|
||||
- Filtering by character level and prerequisites
|
||||
- Checking relationship and flag conditions
|
||||
- Applying probability rolls based on location
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
import structlog
|
||||
|
||||
from app.models.quest import Quest, QuestLoreContext
|
||||
from app.models.character import Character
|
||||
from app.services.quest_service import get_quest_service
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestOfferContext:
|
||||
"""
|
||||
Context for offering a specific quest during NPC conversation.
|
||||
|
||||
Contains all the information needed to inject quest offering
|
||||
context into the AI prompt for natural dialogue generation.
|
||||
|
||||
Attributes:
|
||||
quest: The quest being offered
|
||||
offer_dialogue: NPC-specific dialogue for offering this quest
|
||||
npc_quest_knowledge: Facts the NPC knows about this quest
|
||||
lore_context: Embedded lore for AI context
|
||||
narrative_hooks: Hints for natural conversation weaving
|
||||
"""
|
||||
|
||||
quest: Quest
|
||||
offer_dialogue: str
|
||||
npc_quest_knowledge: List[str]
|
||||
lore_context: QuestLoreContext
|
||||
narrative_hooks: List[str]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary for AI prompt injection."""
|
||||
return {
|
||||
"quest_id": self.quest.quest_id,
|
||||
"quest_name": self.quest.name,
|
||||
"quest_description": self.quest.description,
|
||||
"offer_dialogue": self.offer_dialogue,
|
||||
"npc_quest_knowledge": self.npc_quest_knowledge,
|
||||
"lore_backstory": self.lore_context.backstory,
|
||||
"narrative_hooks": self.narrative_hooks,
|
||||
"rewards": {
|
||||
"gold": self.quest.rewards.gold,
|
||||
"experience": self.quest.rewards.experience,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestEligibilityResult:
|
||||
"""
|
||||
Result of checking quest eligibility for an NPC conversation.
|
||||
|
||||
Attributes:
|
||||
eligible_quests: Quests that can be offered
|
||||
should_offer_quest: Whether to actually offer a quest (after probability roll)
|
||||
selected_quest_context: Context for the quest to offer (if should_offer)
|
||||
blocking_reasons: Why certain quests were filtered out
|
||||
"""
|
||||
|
||||
eligible_quests: List[Quest] = field(default_factory=list)
|
||||
should_offer_quest: bool = False
|
||||
selected_quest_context: Optional[QuestOfferContext] = None
|
||||
blocking_reasons: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"eligible_quest_count": len(self.eligible_quests),
|
||||
"should_offer_quest": self.should_offer_quest,
|
||||
"selected_quest": (
|
||||
self.selected_quest_context.to_dict()
|
||||
if self.selected_quest_context
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class QuestEligibilityService:
|
||||
"""
|
||||
Service for determining quest offering eligibility.
|
||||
|
||||
This is the core service that implements the quest-centric design:
|
||||
given an NPC and a character, determine which quests can be offered
|
||||
and whether an offer should be made based on probability.
|
||||
"""
|
||||
|
||||
# Default probability weights by location type
|
||||
DEFAULT_PROBABILITY_WEIGHTS = {
|
||||
"tavern": 0.35,
|
||||
"town": 0.25,
|
||||
"shop": 0.20,
|
||||
"wilderness": 0.05,
|
||||
"dungeon": 0.10,
|
||||
"road": 0.10,
|
||||
}
|
||||
|
||||
# Maximum active quests a character can have
|
||||
MAX_ACTIVE_QUESTS = 2
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the eligibility service."""
|
||||
self.quest_service = get_quest_service()
|
||||
logger.info("QuestEligibilityService initialized")
|
||||
|
||||
def check_eligibility(
|
||||
self,
|
||||
npc_id: str,
|
||||
character: Character,
|
||||
location_type: str = "town",
|
||||
location_id: str = "",
|
||||
force_probability: Optional[float] = None,
|
||||
) -> QuestEligibilityResult:
|
||||
"""
|
||||
Check which quests an NPC can offer to a character.
|
||||
|
||||
This is the main entry point for quest eligibility checking.
|
||||
It performs the full pipeline:
|
||||
1. Find quests where NPC is quest_giver
|
||||
2. Filter by character eligibility
|
||||
3. Apply probability roll
|
||||
4. Build offer context if successful
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
character: The player character
|
||||
location_type: Type of current location (for probability)
|
||||
location_id: Specific location ID
|
||||
force_probability: Override probability roll (for testing)
|
||||
|
||||
Returns:
|
||||
QuestEligibilityResult with eligible quests and offer context
|
||||
"""
|
||||
result = QuestEligibilityResult()
|
||||
|
||||
# Check if character already has max quests
|
||||
if len(character.active_quests) >= self.MAX_ACTIVE_QUESTS:
|
||||
logger.debug(
|
||||
"Character at max active quests",
|
||||
character_id=character.character_id,
|
||||
active_quests=len(character.active_quests),
|
||||
)
|
||||
result.blocking_reasons["max_quests"] = "Character already has maximum active quests"
|
||||
return result
|
||||
|
||||
# Get quests this NPC can offer
|
||||
npc_quests = self.quest_service.get_quests_for_npc(npc_id)
|
||||
if not npc_quests:
|
||||
logger.debug("NPC has no quests to offer", npc_id=npc_id)
|
||||
return result
|
||||
|
||||
# Filter quests by eligibility
|
||||
for quest in npc_quests:
|
||||
eligible, reason = self._check_quest_eligibility(
|
||||
quest, character, npc_id, location_id
|
||||
)
|
||||
if eligible:
|
||||
result.eligible_quests.append(quest)
|
||||
else:
|
||||
result.blocking_reasons[quest.quest_id] = reason
|
||||
|
||||
if not result.eligible_quests:
|
||||
logger.debug(
|
||||
"No eligible quests after filtering",
|
||||
npc_id=npc_id,
|
||||
character_id=character.character_id,
|
||||
)
|
||||
return result
|
||||
|
||||
# Apply probability roll
|
||||
probability = self._get_offer_probability(
|
||||
result.eligible_quests, location_type, force_probability
|
||||
)
|
||||
roll = random.random()
|
||||
|
||||
if roll > probability:
|
||||
logger.debug(
|
||||
"Probability roll failed",
|
||||
probability=probability,
|
||||
roll=roll,
|
||||
location_type=location_type,
|
||||
)
|
||||
return result
|
||||
|
||||
# Select a quest to offer
|
||||
selected_quest = self._select_quest_to_offer(result.eligible_quests)
|
||||
if not selected_quest:
|
||||
return result
|
||||
|
||||
# Build offer context
|
||||
result.should_offer_quest = True
|
||||
result.selected_quest_context = self._build_offer_context(
|
||||
selected_quest, npc_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Quest offer prepared",
|
||||
quest_id=selected_quest.quest_id,
|
||||
npc_id=npc_id,
|
||||
character_id=character.character_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _check_quest_eligibility(
|
||||
self,
|
||||
quest: Quest,
|
||||
character: Character,
|
||||
npc_id: str,
|
||||
location_id: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if a specific quest is eligible for a character.
|
||||
|
||||
Args:
|
||||
quest: The quest to check
|
||||
character: The player character
|
||||
npc_id: The NPC offering the quest
|
||||
location_id: Current location ID
|
||||
|
||||
Returns:
|
||||
Tuple of (is_eligible, reason_if_not)
|
||||
"""
|
||||
triggers = quest.offering_triggers
|
||||
|
||||
# Check character level
|
||||
if character.level < triggers.min_character_level:
|
||||
return False, f"Character level too low (need {triggers.min_character_level})"
|
||||
|
||||
if character.level > triggers.max_character_level:
|
||||
return False, f"Character level too high (max {triggers.max_character_level})"
|
||||
|
||||
# Check prerequisite quests
|
||||
# Note: We need to check completed_quests on character
|
||||
# For now, check active_quests doesn't contain prereqs (they should be completed)
|
||||
completed_quests = getattr(character, 'completed_quests', [])
|
||||
for prereq_id in triggers.required_quests_completed:
|
||||
if prereq_id not in completed_quests:
|
||||
return False, f"Prerequisite quest not completed: {prereq_id}"
|
||||
|
||||
# Check quest is not already active
|
||||
if quest.quest_id in character.active_quests:
|
||||
return False, "Quest already active"
|
||||
|
||||
# Check quest is not already completed
|
||||
if quest.quest_id in completed_quests:
|
||||
return False, "Quest already completed"
|
||||
|
||||
# Check NPC-specific conditions
|
||||
npc_conditions = quest.get_offer_conditions(npc_id)
|
||||
npc_interaction = character.npc_interactions.get(npc_id, {})
|
||||
|
||||
# Check relationship level
|
||||
relationship = npc_interaction.get("relationship_level", 50)
|
||||
if relationship < npc_conditions.min_relationship:
|
||||
return False, f"Relationship too low (need {npc_conditions.min_relationship})"
|
||||
|
||||
# Check required flags
|
||||
custom_flags = npc_interaction.get("custom_flags", {})
|
||||
for required_flag in npc_conditions.required_flags:
|
||||
if not custom_flags.get(required_flag):
|
||||
return False, f"Required flag not set: {required_flag}"
|
||||
|
||||
# Check forbidden flags
|
||||
for forbidden_flag in npc_conditions.forbidden_flags:
|
||||
if custom_flags.get(forbidden_flag):
|
||||
return False, f"Forbidden flag is set: {forbidden_flag}"
|
||||
|
||||
return True, ""
|
||||
|
||||
def _get_offer_probability(
|
||||
self,
|
||||
eligible_quests: List[Quest],
|
||||
location_type: str,
|
||||
force_probability: Optional[float] = None,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the probability of offering a quest.
|
||||
|
||||
Uses the highest probability weight among eligible quests
|
||||
for the given location type.
|
||||
|
||||
Args:
|
||||
eligible_quests: Quests that passed eligibility check
|
||||
location_type: Type of current location
|
||||
force_probability: Override value (for testing)
|
||||
|
||||
Returns:
|
||||
Probability between 0.0 and 1.0
|
||||
"""
|
||||
if force_probability is not None:
|
||||
return force_probability
|
||||
|
||||
# Find the highest probability weight for this location
|
||||
max_probability = 0.0
|
||||
for quest in eligible_quests:
|
||||
quest_prob = quest.offering_triggers.get_probability(location_type)
|
||||
if quest_prob > max_probability:
|
||||
max_probability = quest_prob
|
||||
|
||||
# Fall back to default if no quest defines a probability
|
||||
if max_probability == 0.0:
|
||||
max_probability = self.DEFAULT_PROBABILITY_WEIGHTS.get(location_type, 0.10)
|
||||
|
||||
return max_probability
|
||||
|
||||
def _select_quest_to_offer(self, eligible_quests: List[Quest]) -> Optional[Quest]:
|
||||
"""
|
||||
Select which quest to offer from eligible quests.
|
||||
|
||||
Currently uses simple random selection, but could be enhanced
|
||||
to use AI selection or priority weights.
|
||||
|
||||
Args:
|
||||
eligible_quests: Quests that can be offered
|
||||
|
||||
Returns:
|
||||
Selected quest or None
|
||||
"""
|
||||
if not eligible_quests:
|
||||
return None
|
||||
|
||||
# Simple random selection for now
|
||||
# TODO: Could use AI selection or difficulty-based priority
|
||||
return random.choice(eligible_quests)
|
||||
|
||||
def _build_offer_context(
|
||||
self,
|
||||
quest: Quest,
|
||||
npc_id: str,
|
||||
) -> QuestOfferContext:
|
||||
"""
|
||||
Build the context for offering a quest.
|
||||
|
||||
Args:
|
||||
quest: The quest being offered
|
||||
npc_id: The NPC offering it
|
||||
|
||||
Returns:
|
||||
QuestOfferContext with all necessary data for AI prompt
|
||||
"""
|
||||
return QuestOfferContext(
|
||||
quest=quest,
|
||||
offer_dialogue=quest.get_offer_dialogue(npc_id),
|
||||
npc_quest_knowledge=quest.get_npc_knowledge(npc_id),
|
||||
lore_context=quest.lore_context,
|
||||
narrative_hooks=quest.dialogue_templates.narrative_hooks,
|
||||
)
|
||||
|
||||
def get_available_quests_for_display(
|
||||
self,
|
||||
npc_id: str,
|
||||
character: Character,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get a list of available quests for UI display.
|
||||
|
||||
This is a simpler check that just returns eligible quests
|
||||
without probability rolls, for showing in a quest list UI.
|
||||
|
||||
Args:
|
||||
npc_id: The NPC's identifier
|
||||
character: The player character
|
||||
|
||||
Returns:
|
||||
List of quest display data
|
||||
"""
|
||||
result = self.check_eligibility(
|
||||
npc_id=npc_id,
|
||||
character=character,
|
||||
force_probability=1.0, # Always include eligible quests
|
||||
)
|
||||
|
||||
return [
|
||||
quest.to_offer_dict()
|
||||
for quest in result.eligible_quests
|
||||
]
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_service_instance: Optional[QuestEligibilityService] = None
|
||||
|
||||
|
||||
def get_quest_eligibility_service() -> QuestEligibilityService:
|
||||
"""
|
||||
Get the global QuestEligibilityService instance.
|
||||
|
||||
Returns:
|
||||
Singleton QuestEligibilityService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = QuestEligibilityService()
|
||||
return _service_instance
|
||||
423
api/app/services/quest_service.py
Normal file
423
api/app/services/quest_service.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
QuestService for loading quest definitions from YAML files.
|
||||
|
||||
This service reads quest configuration files and converts them into Quest
|
||||
dataclass instances, providing caching for performance. Quests are organized
|
||||
by difficulty subdirectories (easy, medium, hard, epic).
|
||||
|
||||
Follows the same pattern as NPCLoader for consistency.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.quest import (
|
||||
Quest,
|
||||
QuestObjective,
|
||||
QuestReward,
|
||||
QuestOfferingTriggers,
|
||||
QuestOfferConditions,
|
||||
NPCOfferDialogue,
|
||||
QuestLoreContext,
|
||||
QuestDialogueTemplates,
|
||||
QuestDifficulty,
|
||||
ObjectiveType,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class QuestService:
|
||||
"""
|
||||
Loads quest definitions from YAML configuration files.
|
||||
|
||||
Quests are organized in difficulty subdirectories:
|
||||
/app/data/quests/
|
||||
easy/
|
||||
cellar_rats.yaml
|
||||
missing_item.yaml
|
||||
medium/
|
||||
bandit_threat.yaml
|
||||
hard/
|
||||
dungeon_depths.yaml
|
||||
epic/
|
||||
ancient_evil.yaml
|
||||
|
||||
This allows game designers to define quests without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the quest service.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing quest YAML files.
|
||||
Defaults to /app/data/quests/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/quests relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "quests")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._quest_cache: Dict[str, Quest] = {}
|
||||
self._npc_quest_cache: Dict[str, List[str]] = {} # npc_id -> [quest_ids]
|
||||
self._difficulty_cache: Dict[str, List[str]] = {} # difficulty -> [quest_ids]
|
||||
|
||||
logger.info("QuestService initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_quest(self, quest_id: str) -> Optional[Quest]:
|
||||
"""
|
||||
Load a single quest by ID.
|
||||
|
||||
Searches all difficulty subdirectories for the quest file.
|
||||
|
||||
Args:
|
||||
quest_id: Unique quest identifier (e.g., "quest_cellar_rats")
|
||||
|
||||
Returns:
|
||||
Quest instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if quest_id in self._quest_cache:
|
||||
logger.debug("Quest loaded from cache", quest_id=quest_id)
|
||||
return self._quest_cache[quest_id]
|
||||
|
||||
# Search in difficulty subdirectories
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Quest data directory does not exist", data_dir=str(self.data_dir))
|
||||
return None
|
||||
|
||||
for difficulty_dir in self.data_dir.iterdir():
|
||||
if not difficulty_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Try both with and without quest_ prefix
|
||||
for filename in [f"{quest_id}.yaml", f"{quest_id.replace('quest_', '')}.yaml"]:
|
||||
file_path = difficulty_dir / filename
|
||||
if file_path.exists():
|
||||
return self._load_quest_file(file_path)
|
||||
|
||||
logger.warning("Quest not found", quest_id=quest_id)
|
||||
return None
|
||||
|
||||
def _load_quest_file(self, file_path: Path) -> Optional[Quest]:
|
||||
"""
|
||||
Load a quest from a specific file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Quest instance or None if loading fails
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
quest = self._parse_quest_data(data)
|
||||
self._quest_cache[quest.quest_id] = quest
|
||||
|
||||
# Update NPC-to-quest cache
|
||||
for npc_id in quest.quest_giver_npc_ids:
|
||||
if npc_id not in self._npc_quest_cache:
|
||||
self._npc_quest_cache[npc_id] = []
|
||||
if quest.quest_id not in self._npc_quest_cache[npc_id]:
|
||||
self._npc_quest_cache[npc_id].append(quest.quest_id)
|
||||
|
||||
# Update difficulty cache
|
||||
difficulty = quest.difficulty.value
|
||||
if difficulty not in self._difficulty_cache:
|
||||
self._difficulty_cache[difficulty] = []
|
||||
if quest.quest_id not in self._difficulty_cache[difficulty]:
|
||||
self._difficulty_cache[difficulty].append(quest.quest_id)
|
||||
|
||||
logger.info("Quest loaded successfully", quest_id=quest.quest_id)
|
||||
return quest
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load quest", file=str(file_path), error=str(e))
|
||||
return None
|
||||
|
||||
def _parse_quest_data(self, data: Dict) -> Quest:
|
||||
"""
|
||||
Parse YAML data into a Quest dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
Quest instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["quest_id", "name"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse objectives
|
||||
objectives = []
|
||||
for obj_data in data.get("objectives", []):
|
||||
objectives.append(QuestObjective(
|
||||
objective_id=obj_data["objective_id"],
|
||||
description=obj_data["description"],
|
||||
objective_type=ObjectiveType(obj_data.get("objective_type", "kill")),
|
||||
required_progress=obj_data.get("required_progress", 1),
|
||||
current_progress=0,
|
||||
target_enemy_type=obj_data.get("target_enemy_type"),
|
||||
target_item_id=obj_data.get("target_item_id"),
|
||||
target_location_id=obj_data.get("target_location_id"),
|
||||
target_npc_id=obj_data.get("target_npc_id"),
|
||||
))
|
||||
|
||||
# Parse rewards
|
||||
rewards_data = data.get("rewards", {})
|
||||
rewards = QuestReward(
|
||||
gold=rewards_data.get("gold", 0),
|
||||
experience=rewards_data.get("experience", 0),
|
||||
items=rewards_data.get("items", []),
|
||||
relationship_bonuses=rewards_data.get("relationship_bonuses", {}),
|
||||
unlocks_quests=rewards_data.get("unlocks_quests", []),
|
||||
reveals_locations=rewards_data.get("reveals_locations", []),
|
||||
)
|
||||
|
||||
# Parse offering triggers
|
||||
triggers_data = data.get("offering_triggers", {})
|
||||
offering_triggers = QuestOfferingTriggers(
|
||||
location_types=triggers_data.get("location_types", []),
|
||||
specific_locations=triggers_data.get("specific_locations", []),
|
||||
min_character_level=triggers_data.get("min_character_level", 1),
|
||||
max_character_level=triggers_data.get("max_character_level", 100),
|
||||
required_quests_completed=triggers_data.get("required_quests_completed", []),
|
||||
probability_weights=triggers_data.get("probability_weights", {}),
|
||||
)
|
||||
|
||||
# Parse NPC offer dialogues
|
||||
npc_offer_dialogues = {}
|
||||
for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items():
|
||||
conditions_data = dialogue_data.get("conditions", {})
|
||||
conditions = QuestOfferConditions(
|
||||
min_relationship=conditions_data.get("min_relationship", 0),
|
||||
required_flags=conditions_data.get("required_flags", []),
|
||||
forbidden_flags=conditions_data.get("forbidden_flags", []),
|
||||
)
|
||||
npc_offer_dialogues[npc_id] = NPCOfferDialogue(
|
||||
dialogue=dialogue_data.get("dialogue", ""),
|
||||
conditions=conditions,
|
||||
)
|
||||
|
||||
# Parse lore context
|
||||
lore_data = data.get("lore_context", {})
|
||||
lore_context = QuestLoreContext(
|
||||
backstory=lore_data.get("backstory", ""),
|
||||
world_connections=lore_data.get("world_connections", []),
|
||||
regional_hints=lore_data.get("regional_hints", []),
|
||||
)
|
||||
|
||||
# Parse dialogue templates
|
||||
templates_data = data.get("dialogue_templates", {})
|
||||
dialogue_templates = QuestDialogueTemplates(
|
||||
narrative_hooks=templates_data.get("narrative_hooks", []),
|
||||
ambient_hints=templates_data.get("ambient_hints", []),
|
||||
)
|
||||
|
||||
return Quest(
|
||||
quest_id=data["quest_id"],
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
difficulty=QuestDifficulty(data.get("difficulty", "easy")),
|
||||
quest_giver_npc_ids=data.get("quest_giver_npc_ids", []),
|
||||
quest_giver_name=data.get("quest_giver_name", ""),
|
||||
location_id=data.get("location_id", ""),
|
||||
region_id=data.get("region_id", ""),
|
||||
objectives=objectives,
|
||||
rewards=rewards,
|
||||
offering_triggers=offering_triggers,
|
||||
npc_offer_dialogues=npc_offer_dialogues,
|
||||
npc_quest_knowledge=data.get("npc_quest_knowledge", {}),
|
||||
lore_context=lore_context,
|
||||
dialogue_templates=dialogue_templates,
|
||||
completion_dialogue=data.get("completion_dialogue", {}),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def load_all_quests(self) -> List[Quest]:
|
||||
"""
|
||||
Load all quests from all difficulty directories.
|
||||
|
||||
Returns:
|
||||
List of Quest instances
|
||||
"""
|
||||
quests = []
|
||||
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Quest data directory does not exist", data_dir=str(self.data_dir))
|
||||
return quests
|
||||
|
||||
for difficulty_dir in self.data_dir.iterdir():
|
||||
if not difficulty_dir.is_dir():
|
||||
continue
|
||||
|
||||
for file_path in difficulty_dir.glob("*.yaml"):
|
||||
quest = self._load_quest_file(file_path)
|
||||
if quest:
|
||||
quests.append(quest)
|
||||
|
||||
logger.info("All quests loaded", count=len(quests))
|
||||
return quests
|
||||
|
||||
def get_quests_for_npc(self, npc_id: str) -> List[Quest]:
|
||||
"""
|
||||
Get all quests that a specific NPC can offer.
|
||||
|
||||
This is the primary method for the quest-centric design:
|
||||
quests define which NPCs can offer them, and this method
|
||||
finds all quests for a given NPC.
|
||||
|
||||
Args:
|
||||
npc_id: NPC identifier
|
||||
|
||||
Returns:
|
||||
List of Quest instances that this NPC can offer
|
||||
"""
|
||||
# Ensure all quests are loaded
|
||||
if not self._quest_cache:
|
||||
self.load_all_quests()
|
||||
|
||||
quest_ids = self._npc_quest_cache.get(npc_id, [])
|
||||
return [
|
||||
self._quest_cache[quest_id]
|
||||
for quest_id in quest_ids
|
||||
if quest_id in self._quest_cache
|
||||
]
|
||||
|
||||
def get_quests_by_difficulty(self, difficulty: str) -> List[Quest]:
|
||||
"""
|
||||
Get all quests of a specific difficulty.
|
||||
|
||||
Args:
|
||||
difficulty: Difficulty level ("easy", "medium", "hard", "epic")
|
||||
|
||||
Returns:
|
||||
List of Quest instances with this difficulty
|
||||
"""
|
||||
# Ensure all quests are loaded
|
||||
if not self._quest_cache:
|
||||
self.load_all_quests()
|
||||
|
||||
quest_ids = self._difficulty_cache.get(difficulty, [])
|
||||
return [
|
||||
self._quest_cache[quest_id]
|
||||
for quest_id in quest_ids
|
||||
if quest_id in self._quest_cache
|
||||
]
|
||||
|
||||
def get_quests_for_location(self, location_id: str, location_type: str = "") -> List[Quest]:
|
||||
"""
|
||||
Get quests that can be offered at a specific location.
|
||||
|
||||
Args:
|
||||
location_id: Location identifier
|
||||
location_type: Type of location (e.g., "tavern", "town")
|
||||
|
||||
Returns:
|
||||
List of Quest instances that can be offered here
|
||||
"""
|
||||
# Ensure all quests are loaded
|
||||
if not self._quest_cache:
|
||||
self.load_all_quests()
|
||||
|
||||
matching_quests = []
|
||||
for quest in self._quest_cache.values():
|
||||
triggers = quest.offering_triggers
|
||||
|
||||
# Check specific locations
|
||||
if location_id in triggers.specific_locations:
|
||||
matching_quests.append(quest)
|
||||
continue
|
||||
|
||||
# Check location types
|
||||
if location_type and location_type in triggers.location_types:
|
||||
matching_quests.append(quest)
|
||||
continue
|
||||
|
||||
return matching_quests
|
||||
|
||||
def get_all_quest_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available quest IDs.
|
||||
|
||||
Returns:
|
||||
List of quest IDs
|
||||
"""
|
||||
# Ensure all quests are loaded
|
||||
if not self._quest_cache:
|
||||
self.load_all_quests()
|
||||
|
||||
return list(self._quest_cache.keys())
|
||||
|
||||
def reload_quest(self, quest_id: str) -> Optional[Quest]:
|
||||
"""
|
||||
Force reload a quest from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when quest definitions change.
|
||||
|
||||
Args:
|
||||
quest_id: Unique quest identifier
|
||||
|
||||
Returns:
|
||||
Quest instance or None if not found
|
||||
"""
|
||||
# Remove from caches if present
|
||||
if quest_id in self._quest_cache:
|
||||
old_quest = self._quest_cache[quest_id]
|
||||
|
||||
# Remove from NPC cache
|
||||
for npc_id in old_quest.quest_giver_npc_ids:
|
||||
if npc_id in self._npc_quest_cache:
|
||||
self._npc_quest_cache[npc_id] = [
|
||||
qid for qid in self._npc_quest_cache[npc_id]
|
||||
if qid != quest_id
|
||||
]
|
||||
|
||||
# Remove from difficulty cache
|
||||
difficulty = old_quest.difficulty.value
|
||||
if difficulty in self._difficulty_cache:
|
||||
self._difficulty_cache[difficulty] = [
|
||||
qid for qid in self._difficulty_cache[difficulty]
|
||||
if qid != quest_id
|
||||
]
|
||||
|
||||
del self._quest_cache[quest_id]
|
||||
|
||||
return self.load_quest(quest_id)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data. Useful for testing."""
|
||||
self._quest_cache.clear()
|
||||
self._npc_quest_cache.clear()
|
||||
self._difficulty_cache.clear()
|
||||
logger.info("Quest cache cleared")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_service_instance: Optional[QuestService] = None
|
||||
|
||||
|
||||
def get_quest_service() -> QuestService:
|
||||
"""
|
||||
Get the global QuestService instance.
|
||||
|
||||
Returns:
|
||||
Singleton QuestService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = QuestService()
|
||||
return _service_instance
|
||||
@@ -683,6 +683,7 @@ def _process_npc_dialogue_task(
|
||||
- npc_relationship: Optional relationship description
|
||||
- previous_dialogue: Optional list of previous exchanges
|
||||
- npc_knowledge: Optional list of things NPC knows
|
||||
- quest_offering_context: Optional quest offer context from eligibility check
|
||||
session_id: Game session ID
|
||||
character_id: Character ID
|
||||
|
||||
@@ -710,7 +711,8 @@ def _process_npc_dialogue_task(
|
||||
user_tier=user_tier,
|
||||
npc_relationship=context.get('npc_relationship'),
|
||||
previous_dialogue=context.get('previous_dialogue'),
|
||||
npc_knowledge=context.get('npc_knowledge')
|
||||
npc_knowledge=context.get('npc_knowledge'),
|
||||
quest_offering_context=context.get('quest_offering_context')
|
||||
)
|
||||
|
||||
# Get NPC info for result
|
||||
|
||||
Reference in New Issue
Block a user