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

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

View File

@@ -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,
)

View File

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