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

View File

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

View 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">&times;</button>
</div>
{# Status Bar #}
{% if quest_complete %}
<div class="quest-status-bar quest-status-bar--ready">
<span class="status-icon">&#10003;</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">&#9881;</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 %}&#10003;{% else %}&#9675;{% 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">&#9733;</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">&#128176;</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">&#127873;</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>

View 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">&times;</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">&#128100;</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">&#8226;</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">&#9733;</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">&#128176;</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">&#127873;</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">&#9888;</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>

View File

@@ -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">&#10003;</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 %}&#10003;{% else %}&#9675;{% 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">&#128220;</span>
<p class="empty-text">No active quests.</p>
<p class="empty-hint">Talk to NPCs to find adventures!</p>
</div>
{% endif %}
</div>