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:
@@ -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()">×</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()">×</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>💰 {rewards['gold']} Gold</li>")
|
||||
if rewards.get('experience'):
|
||||
rewards_html.append(f"<li>★ {rewards['experience']} XP</li>")
|
||||
for item in rewards.get('items', []):
|
||||
rewards_html.append(f"<li>🎁 {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()">×</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()">×</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>
|
||||
'''
|
||||
|
||||
@@ -2139,3 +2139,526 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ===== QUEST MODAL STYLES ===== */
|
||||
|
||||
/* Quest Offer Modal */
|
||||
.quest-offer-modal {
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.quest-offer-header-info,
|
||||
.quest-detail-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quest-offer-body,
|
||||
.quest-detail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Quest Giver Section */
|
||||
.quest-offer-giver,
|
||||
.quest-detail-giver {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.quest-giver-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.quest-giver-name {
|
||||
font-weight: 600;
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.quest-giver-says {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.quest-giver-label {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Quest Dialogue */
|
||||
.quest-offer-dialogue {
|
||||
padding: 1rem;
|
||||
background: rgba(243, 156, 18, 0.1);
|
||||
border-left: 3px solid var(--accent-gold);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.quest-dialogue-text {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Quest Description */
|
||||
.quest-offer-description,
|
||||
.quest-detail-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.quest-offer-description p,
|
||||
.quest-detail-description p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Quest Sections */
|
||||
.quest-offer-section,
|
||||
.quest-detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quest-section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 1px solid var(--play-border);
|
||||
}
|
||||
|
||||
/* Quest Objectives in Modal */
|
||||
.quest-offer-objectives {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quest-offer-objective {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.objective-bullet {
|
||||
color: var(--accent-gold);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.objective-text {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.objective-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Quest Detail Objectives with Progress */
|
||||
.quest-detail-objectives {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.quest-detail-objective {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.quest-detail-objective.objective-complete {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.objective-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.objective-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--play-border);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.objective-complete .objective-check {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-complete,
|
||||
.text-strikethrough {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.objective-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-gold), var(--accent-gold-hover));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Quest Rewards Grid */
|
||||
.quest-rewards-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.quest-reward-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reward-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.reward-icon--xp {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.reward-icon--gold {
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.reward-icon--item {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.reward-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Quest Status Bar */
|
||||
.quest-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quest-status-bar--active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.quest-status-bar--ready {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.quest-status-bar--active .status-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.quest-status-bar--ready .status-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Quest Warning */
|
||||
.quest-offer-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Quest Meta Info */
|
||||
.quest-detail-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--play-border);
|
||||
}
|
||||
|
||||
/* Quest Footer Actions */
|
||||
.quest-offer-footer,
|
||||
.quest-detail-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Quest Success/Complete/Abandon Modals */
|
||||
.quest-success-modal,
|
||||
.quest-complete-modal,
|
||||
.quest-abandon-modal,
|
||||
.quest-error-modal {
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quest-success-text,
|
||||
.quest-complete-title {
|
||||
font-size: var(--text-lg);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quest-success-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.quest-level-up {
|
||||
padding: 0.75rem;
|
||||
background: rgba(243, 156, 18, 0.2);
|
||||
border: 1px solid var(--accent-gold);
|
||||
border-radius: 6px;
|
||||
color: var(--accent-gold);
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.quest-rewards-received {
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.quest-rewards-received h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quest-rewards-received ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.quest-rewards-received li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quest-error-text {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ===== ENHANCED SIDEBAR QUEST STYLES ===== */
|
||||
|
||||
/* Clickable Quest Items */
|
||||
.quest-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.quest-item:hover {
|
||||
border-color: var(--accent-gold);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.quest-item:focus {
|
||||
outline: 2px solid var(--accent-gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.quest-item:active {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
/* Ready to Complete State */
|
||||
.quest-item--ready {
|
||||
border: 1px solid rgba(16, 185, 129, 0.5);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.quest-item--ready:hover {
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
/* Ready Banner in Sidebar */
|
||||
.quest-ready-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ready-icon {
|
||||
color: #10b981;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.ready-text {
|
||||
color: #10b981;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Overall Quest Progress Bar */
|
||||
.quest-overall-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--play-border);
|
||||
}
|
||||
|
||||
.mini-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-gold);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.mini-progress-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty Quest State */
|
||||
.quest-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Difficulty Epic (for future quests) */
|
||||
.quest-difficulty--epic {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
/* Danger Button for Abandon */
|
||||
.btn--danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.btn--danger:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
135
public_web/templates/game/partials/quest_detail_modal.html
Normal file
135
public_web/templates/game/partials/quest_detail_modal.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{#
|
||||
Quest Detail Modal
|
||||
Shows detailed progress on an active quest with abandon option
|
||||
#}
|
||||
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
|
||||
role="dialog" aria-modal="true" aria-labelledby="quest-detail-title">
|
||||
<div class="modal-content quest-detail-modal">
|
||||
{# Header #}
|
||||
<div class="modal-header">
|
||||
<div class="quest-detail-header-info">
|
||||
<h2 class="modal-title" id="quest-detail-title">{{ quest.name }}</h2>
|
||||
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
|
||||
{{ quest.difficulty|title }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal()" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
{# Status Bar #}
|
||||
{% if quest_complete %}
|
||||
<div class="quest-status-bar quest-status-bar--ready">
|
||||
<span class="status-icon">✓</span>
|
||||
<span class="status-text">Ready to Complete!</span>
|
||||
<span class="status-hint">Return to {{ quest.quest_giver_name|default('the quest giver') }} to claim your rewards.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="quest-status-bar quest-status-bar--active">
|
||||
<span class="status-icon">⚙</span>
|
||||
<span class="status-text">In Progress</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Body #}
|
||||
<div class="modal-body quest-detail-body">
|
||||
{# Quest Giver #}
|
||||
{% if quest.quest_giver_name %}
|
||||
<div class="quest-detail-giver">
|
||||
<span class="quest-giver-label">Quest Giver:</span>
|
||||
<span class="quest-giver-name">{{ quest.quest_giver_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Description #}
|
||||
<div class="quest-detail-description">
|
||||
<p>{{ quest.description }}</p>
|
||||
</div>
|
||||
|
||||
{# Objectives Section with Progress #}
|
||||
<div class="quest-detail-section">
|
||||
<h3 class="quest-section-title">Objectives</h3>
|
||||
<ul class="quest-detail-objectives">
|
||||
{% for obj in objectives %}
|
||||
<li class="quest-detail-objective {% if obj.is_complete %}objective-complete{% endif %}">
|
||||
<div class="objective-header">
|
||||
<span class="objective-check">
|
||||
{% if obj.is_complete %}✓{% else %}○{% endif %}
|
||||
</span>
|
||||
<span class="objective-text {% if obj.is_complete %}text-complete{% endif %}">
|
||||
{{ obj.description }}
|
||||
</span>
|
||||
</div>
|
||||
{% if obj.required_progress > 1 %}
|
||||
<div class="objective-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"
|
||||
style="width: {{ (obj.current_progress / obj.required_progress * 100)|int }}%">
|
||||
</div>
|
||||
</div>
|
||||
<span class="progress-text">{{ obj.current_progress }}/{{ obj.required_progress }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Rewards Section #}
|
||||
<div class="quest-detail-section">
|
||||
<h3 class="quest-section-title">Rewards</h3>
|
||||
<div class="quest-rewards-grid">
|
||||
{% if quest.rewards.experience %}
|
||||
<div class="quest-reward-item">
|
||||
<span class="reward-icon reward-icon--xp">★</span>
|
||||
<span class="reward-value">{{ quest.rewards.experience }} XP</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if quest.rewards.gold %}
|
||||
<div class="quest-reward-item">
|
||||
<span class="reward-icon reward-icon--gold">💰</span>
|
||||
<span class="reward-value">{{ quest.rewards.gold }} Gold</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if quest.rewards.items %}
|
||||
{% for item_id in quest.rewards.items %}
|
||||
<div class="quest-reward-item">
|
||||
<span class="reward-icon reward-icon--item">🎁</span>
|
||||
<span class="reward-value">{{ item_id|replace('_', ' ')|title }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Accepted Date #}
|
||||
{% if accepted_at %}
|
||||
<div class="quest-detail-meta">
|
||||
<span class="meta-label">Accepted:</span>
|
||||
<span class="meta-value">{{ accepted_at }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="modal-footer quest-detail-footer">
|
||||
{% if quest_complete %}
|
||||
<button class="btn btn--primary"
|
||||
hx-post="{{ url_for('game.quest_complete', session_id=session_id) }}"
|
||||
hx-vals='{"quest_id": "{{ quest.quest_id }}"}'
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'questCompleted')">
|
||||
Claim Rewards
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn--danger"
|
||||
onclick="if(confirm('Are you sure you want to abandon this quest? All progress will be lost.')) { htmx.ajax('POST', '{{ url_for('game.quest_abandon', session_id=session_id) }}', {target: '#modal-container', swap: 'innerHTML', values: {'quest_id': '{{ quest.quest_id }}'}}); htmx.trigger(document.body, 'questAbandoned'); }">
|
||||
Abandon Quest
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn--secondary" onclick="closeModal()">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
113
public_web/templates/game/partials/quest_offer_modal.html
Normal file
113
public_web/templates/game/partials/quest_offer_modal.html
Normal file
@@ -0,0 +1,113 @@
|
||||
{#
|
||||
Quest Offer Modal
|
||||
Displays a quest being offered by an NPC with accept/decline options
|
||||
#}
|
||||
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
|
||||
role="dialog" aria-modal="true" aria-labelledby="quest-offer-title">
|
||||
<div class="modal-content quest-offer-modal">
|
||||
{# Header #}
|
||||
<div class="modal-header">
|
||||
<div class="quest-offer-header-info">
|
||||
<h2 class="modal-title" id="quest-offer-title">{{ quest.name }}</h2>
|
||||
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
|
||||
{{ quest.difficulty|title }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal()" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
{# Body #}
|
||||
<div class="modal-body quest-offer-body">
|
||||
{# Quest Giver Section #}
|
||||
{% if npc_name or quest.quest_giver_name %}
|
||||
<div class="quest-offer-giver">
|
||||
<span class="quest-giver-icon">👤</span>
|
||||
<span class="quest-giver-name">{{ npc_name|default(quest.quest_giver_name) }}</span>
|
||||
<span class="quest-giver-says">offers you a quest:</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Offer Dialogue / Description #}
|
||||
{% if offer_dialogue %}
|
||||
<div class="quest-offer-dialogue">
|
||||
<p class="quest-dialogue-text">"{{ offer_dialogue }}"</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="quest-offer-description">
|
||||
<p>{{ quest.description }}</p>
|
||||
</div>
|
||||
|
||||
{# Objectives Section #}
|
||||
<div class="quest-offer-section">
|
||||
<h3 class="quest-section-title">Objectives</h3>
|
||||
<ul class="quest-offer-objectives">
|
||||
{% for obj in quest.objectives %}
|
||||
<li class="quest-offer-objective">
|
||||
<span class="objective-bullet">•</span>
|
||||
<span class="objective-text">{{ obj.description }}</span>
|
||||
{% if obj.required_progress > 1 %}
|
||||
<span class="objective-count">(0/{{ obj.required_progress }})</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Rewards Section #}
|
||||
<div class="quest-offer-section">
|
||||
<h3 class="quest-section-title">Rewards</h3>
|
||||
<div class="quest-rewards-grid">
|
||||
{% if quest.rewards.experience %}
|
||||
<div class="quest-reward-item">
|
||||
<span class="reward-icon reward-icon--xp">★</span>
|
||||
<span class="reward-value">{{ quest.rewards.experience }} XP</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if quest.rewards.gold %}
|
||||
<div class="quest-reward-item">
|
||||
<span class="reward-icon reward-icon--gold">💰</span>
|
||||
<span class="reward-value">{{ quest.rewards.gold }} Gold</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if quest.rewards.items %}
|
||||
{% for item_id in quest.rewards.items %}
|
||||
<div class="quest-reward-item">
|
||||
<span class="reward-icon reward-icon--item">🎁</span>
|
||||
<span class="reward-value">{{ item_id|replace('_', ' ')|title }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Warning if at max quests #}
|
||||
{% if at_max_quests %}
|
||||
<div class="quest-offer-warning">
|
||||
<span class="warning-icon">⚠</span>
|
||||
<span class="warning-text">You already have 2 active quests. Complete or abandon one to accept this quest.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="modal-footer quest-offer-footer">
|
||||
<button class="btn btn--secondary"
|
||||
hx-post="{{ url_for('game.quest_decline', session_id=session_id) }}"
|
||||
hx-vals='{"quest_id": "{{ quest.quest_id }}", "npc_id": "{{ npc_id|default('') }}"}'
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML">
|
||||
Decline
|
||||
</button>
|
||||
<button class="btn btn--primary"
|
||||
{% if at_max_quests %}disabled{% endif %}
|
||||
hx-post="{{ url_for('game.quest_accept', session_id=session_id) }}"
|
||||
hx-vals='{"quest_id": "{{ quest.quest_id }}", "npc_id": "{{ npc_id|default('') }}"}'
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'questAccepted')">
|
||||
Accept Quest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,36 +1,83 @@
|
||||
{#
|
||||
Quests Accordion Content
|
||||
Shows active quests with objectives and progress
|
||||
Enhanced with HTMX for live updates and clickable quest details
|
||||
#}
|
||||
<div id="quest-list-container"
|
||||
hx-get="{{ url_for('game.quests_accordion', session_id=session_id) }}"
|
||||
hx-trigger="questAccepted from:body, questCompleted from:body, questAbandoned from:body, combatEnded from:body"
|
||||
hx-swap="innerHTML">
|
||||
|
||||
{% if quests %}
|
||||
<div class="quest-list">
|
||||
{% for quest in quests %}
|
||||
<div class="quest-item">
|
||||
<div class="quest-item {% if quest.is_complete %}quest-item--ready{% endif %}"
|
||||
hx-get="{{ url_for('game.quest_detail', session_id=session_id, quest_id=quest.quest_id) }}"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="View quest details: {{ quest.name }}">
|
||||
|
||||
{# Ready to Complete Banner #}
|
||||
{% if quest.is_complete %}
|
||||
<div class="quest-ready-banner">
|
||||
<span class="ready-icon">✓</span>
|
||||
<span class="ready-text">Ready to Turn In!</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="quest-header">
|
||||
<span class="quest-name">{{ quest.name }}</span>
|
||||
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
|
||||
{{ quest.difficulty }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if quest.quest_giver %}
|
||||
<div class="quest-giver">From: {{ quest.quest_giver }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="quest-objectives">
|
||||
{% for objective in quest.objectives %}
|
||||
<div class="quest-objective">
|
||||
<span class="quest-objective-check {% if objective.completed %}completed{% endif %}">
|
||||
{% if objective.completed %}✓{% endif %}
|
||||
<div class="quest-objective {% if objective.is_complete %}objective-complete{% endif %}">
|
||||
<span class="quest-objective-check">
|
||||
{% if objective.is_complete %}✓{% else %}○{% endif %}
|
||||
</span>
|
||||
<span class="quest-objective-text {% if objective.is_complete %}text-strikethrough{% endif %}">
|
||||
{{ objective.description }}
|
||||
</span>
|
||||
<span class="quest-objective-text">{{ objective.description }}</span>
|
||||
{% if objective.required > 1 %}
|
||||
<span class="quest-objective-progress">{{ objective.current }}/{{ objective.required }}</span>
|
||||
<span class="quest-objective-progress">
|
||||
{{ objective.current }}/{{ objective.required }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Progress Bar for Multi-objective Quests #}
|
||||
{% if quest.objectives|length > 1 %}
|
||||
{% set completed_count = quest.objectives|selectattr('is_complete')|list|length %}
|
||||
{% set total_count = quest.objectives|length %}
|
||||
<div class="quest-overall-progress">
|
||||
<div class="mini-progress-bar">
|
||||
<div class="mini-progress-fill"
|
||||
style="width: {{ (completed_count / total_count * 100)|int }}%">
|
||||
</div>
|
||||
</div>
|
||||
<span class="mini-progress-text">{{ completed_count }}/{{ total_count }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="quest-empty">
|
||||
No active quests. Talk to NPCs to find adventures!
|
||||
<span class="empty-icon">📜</span>
|
||||
<p class="empty-text">No active quests.</p>
|
||||
<p class="empty-hint">Talk to NPCs to find adventures!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user