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