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

@@ -366,20 +366,73 @@ def history_accordion(session_id: str):
@game_bp.route('/session/<session_id>/quests')
@require_auth
def quests_accordion(session_id: str):
"""Refresh quests accordion content."""
"""
Refresh quests accordion content.
Fetches full quest data with progress from the character's quest states,
enriching each quest with objective progress information.
"""
client = get_api_client()
try:
# Get session to access game_state.active_quests
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {})
quests = game_state.get('active_quests', [])
character_id = session_data.get('character_id')
enriched_quests = []
if character_id:
try:
# Get character's quests with progress
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_quests = quests_data.get('active_quests', [])
# Process each quest to add display-friendly data
for quest in active_quests:
progress_data = quest.get('progress', {})
objectives_progress = progress_data.get('objectives_progress', {})
# Enrich objectives with progress
enriched_objectives = []
all_complete = True
for obj in quest.get('objectives', []):
obj_id = obj.get('objective_id', obj.get('description', ''))
current = objectives_progress.get(obj_id, 0)
# Parse required from progress_text or use default
progress_text = obj.get('progress_text', '0/1')
required = int(progress_text.split('/')[1]) if '/' in progress_text else 1
is_complete = current >= required
if not is_complete:
all_complete = False
enriched_objectives.append({
'description': obj.get('description', ''),
'current': current,
'required': required,
'is_complete': is_complete
})
enriched_quests.append({
'quest_id': quest.get('quest_id', ''),
'name': quest.get('name', 'Unknown Quest'),
'description': quest.get('description', ''),
'difficulty': quest.get('difficulty', 'easy'),
'quest_giver': quest.get('quest_giver_name', ''),
'objectives': enriched_objectives,
'rewards': quest.get('rewards', {}),
'is_complete': all_complete
})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_character_quests", character_id=character_id, error=str(e))
return render_template(
'game/partials/sidebar_quests.html',
session_id=session_id,
quests=quests
quests=enriched_quests
)
except APIError as e:
logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e))
@@ -1586,3 +1639,403 @@ def shop_sell(session_id: str):
def shop_modal_with_error(session_id: str, error: str):
"""Helper to render shop modal with an error message."""
return redirect(url_for('game.shop_modal', session_id=session_id, error=error))
# ===== Quest Routes =====
@game_bp.route('/session/<session_id>/quest/<quest_id>')
@require_auth
def quest_detail(session_id: str, quest_id: str):
"""
Get quest detail modal showing progress and options.
Displays the full quest details with objective progress,
rewards, and options to abandon (if in progress) or
claim rewards (if complete).
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Get quest details
quest_response = client.get(f'/api/v1/quests/{quest_id}')
quest = quest_response.get('result', {})
if not quest:
return _quest_error_modal(f"Quest not found: {quest_id}")
# Get character's quest progress
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_quests = quests_data.get('active_quests', [])
# Find this quest's progress
quest_state = None
objectives_progress = {}
accepted_at = None
for active_quest in active_quests:
if active_quest.get('quest_id') == quest_id:
progress_data = active_quest.get('progress', {})
objectives_progress = progress_data.get('objectives_progress', {})
accepted_at = progress_data.get('accepted_at', '')
break
# Build enriched objectives with progress
enriched_objectives = []
all_complete = True
for obj in quest.get('objectives', []):
obj_id = obj.get('objective_id', '')
progress_text = obj.get('progress_text', '0/1')
required = int(progress_text.split('/')[1]) if '/' in progress_text else 1
current = objectives_progress.get(obj_id, 0)
is_complete = current >= required
if not is_complete:
all_complete = False
enriched_objectives.append({
'objective_id': obj_id,
'description': obj.get('description', ''),
'current_progress': current,
'required_progress': required,
'is_complete': is_complete
})
return render_template(
'game/partials/quest_detail_modal.html',
session_id=session_id,
quest=quest,
objectives=enriched_objectives,
quest_complete=all_complete,
accepted_at=accepted_at
)
except APIError as e:
logger.error("failed_to_load_quest_detail", session_id=session_id, quest_id=quest_id, error=str(e))
return _quest_error_modal(f"Failed to load quest: {e}")
@game_bp.route('/session/<session_id>/quest/offer/<quest_id>')
@require_auth
def quest_offer(session_id: str, quest_id: str):
"""
Display quest offer modal.
Shows quest details when an NPC offers a quest,
with accept/decline options.
"""
client = get_api_client()
npc_id = request.args.get('npc_id', '')
npc_name = request.args.get('npc_name', '')
offer_dialogue = request.args.get('offer_dialogue', '')
try:
# Get session to check character's quest count
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
at_max_quests = False
if character_id:
try:
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_count = quests_data.get('active_count', 0)
at_max_quests = active_count >= 2
except APIError:
pass
# Get quest details
quest_response = client.get(f'/api/v1/quests/{quest_id}')
quest = quest_response.get('result', {})
if not quest:
return _quest_error_modal(f"Quest not found: {quest_id}")
return render_template(
'game/partials/quest_offer_modal.html',
session_id=session_id,
quest=quest,
npc_id=npc_id,
npc_name=npc_name,
offer_dialogue=offer_dialogue,
at_max_quests=at_max_quests
)
except APIError as e:
logger.error("failed_to_load_quest_offer", session_id=session_id, quest_id=quest_id, error=str(e))
return _quest_error_modal(f"Failed to load quest offer: {e}")
@game_bp.route('/session/<session_id>/quest/accept', methods=['POST'])
@require_auth
def quest_accept(session_id: str):
"""
Accept a quest offer.
Calls the API to add the quest to the character's active quests.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id')
npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Accept the quest
accept_response = client.post('/api/v1/quests/accept', json={
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
result = accept_response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
logger.info(
"quest_accepted",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Return success message that will close modal
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-success-modal">
<div class="modal-header">
<h2>Quest Accepted!</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-success-text">You have accepted: <strong>{quest_name}</strong></p>
<p class="quest-success-hint">Check your Quest Log to track your progress.</p>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">Continue</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_accept_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to accept quest: {error_msg}")
@game_bp.route('/session/<session_id>/quest/decline', methods=['POST'])
@require_auth
def quest_decline(session_id: str):
"""
Decline a quest offer.
Sets a flag to prevent immediate re-offering.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Decline the quest
client.post('/api/v1/quests/decline', json={
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
logger.info(
"quest_declined",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Just close the modal
return ''
except APIError as e:
logger.error("quest_decline_failed", session_id=session_id, quest_id=quest_id, error=str(e))
return '' # Close modal anyway
@game_bp.route('/session/<session_id>/quest/abandon', methods=['POST'])
@require_auth
def quest_abandon(session_id: str):
"""
Abandon an active quest.
Removes the quest from active quests.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Abandon the quest
client.post('/api/v1/quests/abandon', json={
'character_id': character_id,
'quest_id': quest_id
})
logger.info(
"quest_abandoned",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Return confirmation that will close modal
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-abandon-modal">
<div class="modal-header">
<h2>Quest Abandoned</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p>You have abandoned the quest. You can accept it again later if offered.</p>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">OK</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_abandon_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to abandon quest: {error_msg}")
@game_bp.route('/session/<session_id>/quest/complete', methods=['POST'])
@require_auth
def quest_complete(session_id: str):
"""
Complete a quest and claim rewards.
Grants rewards and moves quest to completed list.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Complete the quest
complete_response = client.post('/api/v1/quests/complete', json={
'character_id': character_id,
'quest_id': quest_id
})
result = complete_response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
rewards = result.get('rewards', {})
leveled_up = result.get('leveled_up', False)
new_level = result.get('new_level')
logger.info(
"quest_completed",
session_id=session_id,
character_id=character_id,
quest_id=quest_id,
rewards=rewards
)
# Build rewards display
rewards_html = []
if rewards.get('gold'):
rewards_html.append(f"<li>&#128176; {rewards['gold']} Gold</li>")
if rewards.get('experience'):
rewards_html.append(f"<li>&#9733; {rewards['experience']} XP</li>")
for item in rewards.get('items', []):
rewards_html.append(f"<li>&#127873; {item}</li>")
level_up_html = ""
if leveled_up and new_level:
level_up_html = f'<div class="quest-level-up">Level Up! You are now level {new_level}!</div>'
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-complete-modal">
<div class="modal-header">
<h2>Quest Complete!</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-complete-title">Completed: <strong>{quest_name}</strong></p>
{level_up_html}
<div class="quest-rewards-received">
<h4>Rewards:</h4>
<ul>{"".join(rewards_html)}</ul>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">Excellent!</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_complete_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to complete quest: {error_msg}")
def _quest_error_modal(error_message: str) -> str:
"""Helper to render a quest error modal."""
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-error-modal">
<div class="modal-header">
<h2>Error</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-error-text">{error_message}</p>
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
'''