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:
2025-11-29 15:42:55 -06:00
parent e7e329e6ed
commit df26abd207
42 changed files with 8421 additions and 2227 deletions

View File

@@ -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')

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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 %}

View File

@@ -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
View 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",
)

View File

@@ -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: []

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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
View 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})"

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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