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>
244 lines
7.6 KiB
Python
244 lines
7.6 KiB
Python
"""
|
|
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
|
|
|
|
import structlog
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ItemGrant:
|
|
"""
|
|
Represents an item granted by the AI during gameplay.
|
|
|
|
The AI can grant items in two ways:
|
|
1. By item_id - References an existing item from game data
|
|
2. By name/type/description - Creates a generic item
|
|
"""
|
|
item_id: Optional[str] = None # For existing items
|
|
name: Optional[str] = None # For generic items
|
|
item_type: Optional[str] = None # consumable, weapon, armor, quest_item
|
|
description: Optional[str] = None
|
|
value: int = 0
|
|
quantity: int = 1
|
|
|
|
def is_existing_item(self) -> bool:
|
|
"""Check if this references an existing item by ID."""
|
|
return self.item_id is not None
|
|
|
|
def is_generic_item(self) -> bool:
|
|
"""Check if this is a generic item created by the AI."""
|
|
return self.item_id is None and self.name is not None
|
|
|
|
|
|
@dataclass
|
|
class GameStateChanges:
|
|
"""
|
|
Structured game state changes extracted from AI response.
|
|
|
|
These changes are validated and applied to the character after
|
|
the AI generates its narrative response.
|
|
"""
|
|
items_given: list[ItemGrant] = field(default_factory=list)
|
|
items_taken: list[str] = field(default_factory=list) # item_ids to remove
|
|
gold_given: int = 0
|
|
gold_taken: int = 0
|
|
experience_given: int = 0
|
|
quest_offered: Optional[str] = None # quest_id
|
|
quest_completed: Optional[str] = None # quest_id
|
|
location_change: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class ParsedAIResponse:
|
|
"""
|
|
Complete parsed AI response with narrative and game state changes.
|
|
|
|
Attributes:
|
|
narrative: The narrative text to display to the player
|
|
game_changes: Structured game state changes to apply
|
|
raw_response: The original unparsed response from AI
|
|
parse_success: Whether parsing succeeded
|
|
parse_errors: Any errors encountered during parsing
|
|
"""
|
|
narrative: str
|
|
game_changes: GameStateChanges
|
|
raw_response: str
|
|
parse_success: bool = True
|
|
parse_errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
class ResponseParserError(Exception):
|
|
"""Exception raised when response parsing fails critically."""
|
|
pass
|
|
|
|
|
|
def parse_ai_response(response_text: str) -> ParsedAIResponse:
|
|
"""
|
|
Parse an AI response to extract the narrative text.
|
|
|
|
Game state changes (items, gold, XP) are now handled exclusively through
|
|
predetermined dice check outcomes, not through AI-generated structured data.
|
|
|
|
Args:
|
|
response_text: The raw AI response text
|
|
|
|
Returns:
|
|
ParsedAIResponse with narrative (game_changes will be empty)
|
|
"""
|
|
logger.debug("Parsing AI response", response_length=len(response_text))
|
|
|
|
# Return the full response as narrative
|
|
# Game state changes come from predetermined check_outcomes, not AI
|
|
return ParsedAIResponse(
|
|
narrative=response_text.strip(),
|
|
game_changes=GameStateChanges(),
|
|
raw_response=response_text,
|
|
parse_success=True,
|
|
parse_errors=[]
|
|
)
|
|
|
|
|
|
def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges:
|
|
"""
|
|
Parse the game actions dictionary into a GameStateChanges object.
|
|
|
|
Args:
|
|
data: Dictionary from parsed JSON
|
|
|
|
Returns:
|
|
GameStateChanges object with parsed data
|
|
"""
|
|
changes = GameStateChanges()
|
|
|
|
# Parse items_given
|
|
if "items_given" in data and data["items_given"]:
|
|
for item_data in data["items_given"]:
|
|
if isinstance(item_data, dict):
|
|
item_grant = ItemGrant(
|
|
item_id=item_data.get("item_id"),
|
|
name=item_data.get("name"),
|
|
item_type=item_data.get("type"),
|
|
description=item_data.get("description"),
|
|
value=item_data.get("value", 0),
|
|
quantity=item_data.get("quantity", 1)
|
|
)
|
|
changes.items_given.append(item_grant)
|
|
elif isinstance(item_data, str):
|
|
# Simple string format - treat as item_id
|
|
changes.items_given.append(ItemGrant(item_id=item_data))
|
|
|
|
# Parse items_taken
|
|
if "items_taken" in data and data["items_taken"]:
|
|
changes.items_taken = [
|
|
item_id for item_id in data["items_taken"]
|
|
if isinstance(item_id, str)
|
|
]
|
|
|
|
# Parse gold changes
|
|
changes.gold_given = int(data.get("gold_given", 0))
|
|
changes.gold_taken = int(data.get("gold_taken", 0))
|
|
|
|
# Parse experience
|
|
changes.experience_given = int(data.get("experience_given", 0))
|
|
|
|
# Parse quest changes
|
|
changes.quest_offered = data.get("quest_offered")
|
|
changes.quest_completed = data.get("quest_completed")
|
|
|
|
# Parse location change
|
|
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,
|
|
)
|