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:
@@ -447,7 +447,8 @@ class NarrativeGenerator:
|
||||
user_tier: UserTier,
|
||||
npc_relationship: str | None = None,
|
||||
previous_dialogue: list[dict[str, Any]] | None = None,
|
||||
npc_knowledge: list[str] | None = None
|
||||
npc_knowledge: list[str] | None = None,
|
||||
quest_offering_context: dict[str, Any] | None = None
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate NPC dialogue in response to player conversation.
|
||||
@@ -461,6 +462,7 @@ class NarrativeGenerator:
|
||||
npc_relationship: Optional description of relationship with NPC.
|
||||
previous_dialogue: Optional list of previous exchanges.
|
||||
npc_knowledge: Optional list of things this NPC knows about.
|
||||
quest_offering_context: Optional quest offer context from QuestEligibilityService.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with NPC dialogue.
|
||||
@@ -500,6 +502,7 @@ class NarrativeGenerator:
|
||||
npc_relationship=npc_relationship,
|
||||
previous_dialogue=previous_dialogue or [],
|
||||
npc_knowledge=npc_knowledge or [],
|
||||
quest_offering_context=quest_offering_context,
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
|
||||
@@ -4,8 +4,11 @@ Response parser for AI narrative responses.
|
||||
This module handles AI response parsing. Game state changes (items, gold, XP)
|
||||
are now handled exclusively through predetermined dice check outcomes in
|
||||
action templates, not through AI-generated JSON.
|
||||
|
||||
Quest offers are extracted from NPC dialogue using [QUEST_OFFER:quest_id] markers.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -158,3 +161,83 @@ def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges:
|
||||
changes.location_change = data.get("location_change")
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NPC Dialogue Parsing
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class ParsedNPCDialogue:
|
||||
"""
|
||||
Parsed NPC dialogue response with quest offer extraction.
|
||||
|
||||
When NPCs offer quests during conversation, they include a
|
||||
[QUEST_OFFER:quest_id] marker that signals the UI to show
|
||||
a quest accept/decline modal.
|
||||
|
||||
Attributes:
|
||||
dialogue: The cleaned dialogue text (marker removed)
|
||||
quest_offered: Quest ID if a quest was offered, None otherwise
|
||||
raw_response: The original response text
|
||||
"""
|
||||
|
||||
dialogue: str
|
||||
quest_offered: Optional[str] = None
|
||||
raw_response: str = ""
|
||||
|
||||
|
||||
# Regex pattern for quest offer markers
|
||||
# Matches: [QUEST_OFFER:quest_id] or [QUEST_OFFER: quest_id]
|
||||
QUEST_OFFER_PATTERN = re.compile(r'\[QUEST_OFFER:\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\]')
|
||||
|
||||
|
||||
def parse_npc_dialogue(response_text: str) -> ParsedNPCDialogue:
|
||||
"""
|
||||
Parse an NPC dialogue response, extracting quest offer markers.
|
||||
|
||||
The AI is instructed to include [QUEST_OFFER:quest_id] on its own line
|
||||
when offering a quest. This function extracts the marker and returns
|
||||
the cleaned dialogue.
|
||||
|
||||
Args:
|
||||
response_text: The raw AI dialogue response
|
||||
|
||||
Returns:
|
||||
ParsedNPCDialogue with cleaned dialogue and optional quest_offered
|
||||
|
||||
Example:
|
||||
>>> response = '''*leans in* Got a problem, friend.
|
||||
... [QUEST_OFFER:quest_cellar_rats]
|
||||
... Giant rats in me cellar.'''
|
||||
>>> result = parse_npc_dialogue(response)
|
||||
>>> result.quest_offered
|
||||
'quest_cellar_rats'
|
||||
>>> '[QUEST_OFFER' in result.dialogue
|
||||
False
|
||||
"""
|
||||
logger.debug("Parsing NPC dialogue", response_length=len(response_text))
|
||||
|
||||
quest_offered = None
|
||||
dialogue = response_text.strip()
|
||||
|
||||
# Search for quest offer marker
|
||||
match = QUEST_OFFER_PATTERN.search(dialogue)
|
||||
if match:
|
||||
quest_offered = match.group(1)
|
||||
# Remove the marker from the dialogue
|
||||
dialogue = QUEST_OFFER_PATTERN.sub('', dialogue)
|
||||
# Clean up any extra whitespace/newlines left behind
|
||||
dialogue = re.sub(r'\n\s*\n', '\n\n', dialogue)
|
||||
dialogue = dialogue.strip()
|
||||
|
||||
logger.info(
|
||||
"Quest offer extracted from dialogue",
|
||||
quest_id=quest_offered,
|
||||
)
|
||||
|
||||
return ParsedNPCDialogue(
|
||||
dialogue=dialogue,
|
||||
quest_offered=quest_offered,
|
||||
raw_response=response_text,
|
||||
)
|
||||
|
||||
@@ -92,6 +92,67 @@ Work these into the dialogue naturally - don't dump all information at once.
|
||||
Make it feel earned, like the NPC is opening up to someone they trust.
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context and quest_offering_context.should_offer %}
|
||||
## QUEST OFFERING OPPORTUNITY
|
||||
The NPC has a quest to offer. Weave this naturally into the conversation.
|
||||
|
||||
**Quest:** {{ quest_offering_context.quest_name }}
|
||||
**Quest ID:** {{ quest_offering_context.quest_id }}
|
||||
|
||||
{% if quest_offering_context.offer_dialogue %}
|
||||
**How the NPC Would Present It:**
|
||||
{{ quest_offering_context.offer_dialogue }}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context.npc_quest_knowledge %}
|
||||
**What the NPC Knows About This Quest:**
|
||||
{% for fact in quest_offering_context.npc_quest_knowledge %}
|
||||
- {{ fact }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context.narrative_hooks %}
|
||||
**Narrative Hooks (use 1-2 naturally):**
|
||||
{% for hook in quest_offering_context.narrative_hooks %}
|
||||
- The NPC {{ hook }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
**IMPORTANT QUEST OFFERING RULES:**
|
||||
- Do NOT dump all quest information at once
|
||||
- Let the quest emerge naturally from conversation
|
||||
- If the player seems interested or asks about problems/work, offer the quest
|
||||
- If the player changes topic, don't force it - just mention hints
|
||||
- When you offer the quest, include this marker on its own line: [QUEST_OFFER:{{ quest_offering_context.quest_id }}]
|
||||
- The marker signals the UI to show a quest accept/decline option
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context and lore_context.has_content %}
|
||||
## Relevant World Knowledge
|
||||
The NPC may reference this lore if contextually appropriate:
|
||||
|
||||
{% if lore_context.quest %}
|
||||
**Quest Background:**
|
||||
{% for entry in lore_context.quest[:2] %}
|
||||
- {{ entry.content | truncate_text(150) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context.regional %}
|
||||
**Local Knowledge:**
|
||||
{% for entry in lore_context.regional[:3] %}
|
||||
- {{ entry.content | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context.world %}
|
||||
**Historical Knowledge (if NPC would know):**
|
||||
{% for entry in lore_context.world[:2] %}
|
||||
- {{ entry.content | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.relationships %}
|
||||
## NPC Relationships (for context)
|
||||
{% for rel in npc.relationships %}
|
||||
|
||||
Reference in New Issue
Block a user