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:
392
api/tests/test_quest_models.py
Normal file
392
api/tests/test_quest_models.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Unit tests for Quest data models.
|
||||
|
||||
Tests serialization, deserialization, and key model methods.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.models.quest import (
|
||||
Quest,
|
||||
QuestObjective,
|
||||
QuestReward,
|
||||
QuestOfferingTriggers,
|
||||
QuestOfferConditions,
|
||||
NPCOfferDialogue,
|
||||
QuestLoreContext,
|
||||
QuestDialogueTemplates,
|
||||
CharacterQuestState,
|
||||
QuestDifficulty,
|
||||
ObjectiveType,
|
||||
QuestStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestQuestObjective:
|
||||
"""Tests for QuestObjective dataclass."""
|
||||
|
||||
def test_create_kill_objective(self):
|
||||
"""Test creating a kill-type objective."""
|
||||
obj = QuestObjective(
|
||||
objective_id="kill_rats",
|
||||
description="Kill 10 giant rats",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=10,
|
||||
target_enemy_type="giant_rat",
|
||||
)
|
||||
|
||||
assert obj.objective_id == "kill_rats"
|
||||
assert obj.objective_type == ObjectiveType.KILL
|
||||
assert obj.required_progress == 10
|
||||
assert not obj.is_complete
|
||||
assert obj.progress_text == "0/10"
|
||||
|
||||
def test_objective_completion(self):
|
||||
"""Test objective completion detection."""
|
||||
obj = QuestObjective(
|
||||
objective_id="collect_items",
|
||||
description="Collect 5 herbs",
|
||||
objective_type=ObjectiveType.COLLECT,
|
||||
required_progress=5,
|
||||
current_progress=5,
|
||||
)
|
||||
|
||||
assert obj.is_complete
|
||||
|
||||
def test_objective_serialization(self):
|
||||
"""Test objective to_dict and from_dict."""
|
||||
obj = QuestObjective(
|
||||
objective_id="travel_town",
|
||||
description="Travel to the town",
|
||||
objective_type=ObjectiveType.TRAVEL,
|
||||
required_progress=1,
|
||||
target_location_id="crossville_village",
|
||||
)
|
||||
|
||||
data = obj.to_dict()
|
||||
assert data["objective_id"] == "travel_town"
|
||||
assert data["objective_type"] == "travel"
|
||||
assert data["target_location_id"] == "crossville_village"
|
||||
|
||||
restored = QuestObjective.from_dict(data)
|
||||
assert restored.objective_id == obj.objective_id
|
||||
assert restored.objective_type == obj.objective_type
|
||||
assert restored.target_location_id == obj.target_location_id
|
||||
|
||||
|
||||
class TestQuestReward:
|
||||
"""Tests for QuestReward dataclass."""
|
||||
|
||||
def test_create_reward(self):
|
||||
"""Test creating a quest reward."""
|
||||
reward = QuestReward(
|
||||
gold=100,
|
||||
experience=250,
|
||||
items=["sword_epic"],
|
||||
relationship_bonuses={"npc_grom": 10},
|
||||
unlocks_quests=["quest_sequel"],
|
||||
)
|
||||
|
||||
assert reward.gold == 100
|
||||
assert reward.experience == 250
|
||||
assert "sword_epic" in reward.items
|
||||
assert reward.relationship_bonuses.get("npc_grom") == 10
|
||||
|
||||
def test_reward_serialization(self):
|
||||
"""Test reward to_dict and from_dict."""
|
||||
reward = QuestReward(gold=50, experience=100)
|
||||
data = reward.to_dict()
|
||||
|
||||
restored = QuestReward.from_dict(data)
|
||||
assert restored.gold == 50
|
||||
assert restored.experience == 100
|
||||
|
||||
|
||||
class TestQuestOfferingTriggers:
|
||||
"""Tests for QuestOfferingTriggers dataclass."""
|
||||
|
||||
def test_get_probability(self):
|
||||
"""Test probability lookup by location type."""
|
||||
triggers = QuestOfferingTriggers(
|
||||
location_types=["tavern", "town"],
|
||||
probability_weights={"tavern": 0.35, "town": 0.25},
|
||||
)
|
||||
|
||||
assert triggers.get_probability("tavern") == 0.35
|
||||
assert triggers.get_probability("town") == 0.25
|
||||
assert triggers.get_probability("wilderness") == 0.0 # Not defined
|
||||
|
||||
def test_level_range(self):
|
||||
"""Test level range settings."""
|
||||
triggers = QuestOfferingTriggers(
|
||||
min_character_level=5,
|
||||
max_character_level=15,
|
||||
)
|
||||
|
||||
assert triggers.min_character_level == 5
|
||||
assert triggers.max_character_level == 15
|
||||
|
||||
|
||||
class TestQuest:
|
||||
"""Tests for Quest dataclass."""
|
||||
|
||||
def test_create_quest(self):
|
||||
"""Test creating a complete quest."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="A test quest",
|
||||
difficulty=QuestDifficulty.MEDIUM,
|
||||
quest_giver_npc_ids=["npc_test"],
|
||||
objectives=[
|
||||
QuestObjective(
|
||||
objective_id="obj1",
|
||||
description="Do something",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=5,
|
||||
)
|
||||
],
|
||||
rewards=QuestReward(gold=100, experience=200),
|
||||
)
|
||||
|
||||
assert quest.quest_id == "quest_test"
|
||||
assert quest.difficulty == QuestDifficulty.MEDIUM
|
||||
assert len(quest.objectives) == 1
|
||||
assert quest.rewards.gold == 100
|
||||
|
||||
def test_can_npc_offer(self):
|
||||
"""Test NPC can offer check."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="Test",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
quest_giver_npc_ids=["npc_grom", "npc_hilda"],
|
||||
)
|
||||
|
||||
assert quest.can_npc_offer("npc_grom")
|
||||
assert quest.can_npc_offer("npc_hilda")
|
||||
assert not quest.can_npc_offer("npc_other")
|
||||
|
||||
def test_get_offer_dialogue(self):
|
||||
"""Test getting NPC-specific offer dialogue."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="Test",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
quest_giver_npc_ids=["npc_grom"],
|
||||
npc_offer_dialogues={
|
||||
"npc_grom": NPCOfferDialogue(
|
||||
dialogue="Got a problem, friend.",
|
||||
conditions=QuestOfferConditions(min_relationship=30),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert quest.get_offer_dialogue("npc_grom") == "Got a problem, friend."
|
||||
assert quest.get_offer_dialogue("npc_other") == ""
|
||||
|
||||
def test_get_offer_conditions(self):
|
||||
"""Test getting NPC-specific offer conditions."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="Test",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
quest_giver_npc_ids=["npc_grom"],
|
||||
npc_offer_dialogues={
|
||||
"npc_grom": NPCOfferDialogue(
|
||||
dialogue="Test",
|
||||
conditions=QuestOfferConditions(
|
||||
min_relationship=50,
|
||||
required_flags=["helped_before"],
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
conditions = quest.get_offer_conditions("npc_grom")
|
||||
assert conditions.min_relationship == 50
|
||||
assert "helped_before" in conditions.required_flags
|
||||
|
||||
# Non-existent NPC should return default conditions
|
||||
default_conditions = quest.get_offer_conditions("npc_other")
|
||||
assert default_conditions.min_relationship == 0
|
||||
|
||||
def test_quest_completion(self):
|
||||
"""Test quest completion detection."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="Test",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
quest_giver_npc_ids=["npc_test"],
|
||||
objectives=[
|
||||
QuestObjective(
|
||||
objective_id="obj1",
|
||||
description="Task 1",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=5,
|
||||
current_progress=5,
|
||||
),
|
||||
QuestObjective(
|
||||
objective_id="obj2",
|
||||
description="Task 2",
|
||||
objective_type=ObjectiveType.TRAVEL,
|
||||
required_progress=1,
|
||||
current_progress=1,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert quest.is_complete
|
||||
|
||||
def test_quest_not_complete(self):
|
||||
"""Test quest incomplete when objectives remain."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="Test",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
quest_giver_npc_ids=["npc_test"],
|
||||
objectives=[
|
||||
QuestObjective(
|
||||
objective_id="obj1",
|
||||
description="Task 1",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=5,
|
||||
current_progress=5,
|
||||
),
|
||||
QuestObjective(
|
||||
objective_id="obj2",
|
||||
description="Task 2",
|
||||
objective_type=ObjectiveType.TRAVEL,
|
||||
required_progress=1,
|
||||
current_progress=0, # Not complete
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert not quest.is_complete
|
||||
|
||||
def test_quest_serialization(self):
|
||||
"""Test quest to_dict and from_dict."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="A test description",
|
||||
difficulty=QuestDifficulty.HARD,
|
||||
quest_giver_npc_ids=["npc_grom"],
|
||||
quest_giver_name="Grom",
|
||||
location_id="crossville_tavern",
|
||||
region_id="crossville",
|
||||
objectives=[
|
||||
QuestObjective(
|
||||
objective_id="kill_rats",
|
||||
description="Kill 10 rats",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=10,
|
||||
)
|
||||
],
|
||||
rewards=QuestReward(gold=50, experience=100),
|
||||
lore_context=QuestLoreContext(
|
||||
backstory="Long ago...",
|
||||
world_connections=["Connected to main story"],
|
||||
),
|
||||
dialogue_templates=QuestDialogueTemplates(
|
||||
narrative_hooks=["mentions rats", "looks nervous"],
|
||||
),
|
||||
tags=["combat", "starter"],
|
||||
)
|
||||
|
||||
data = quest.to_dict()
|
||||
assert data["quest_id"] == "quest_test"
|
||||
assert data["difficulty"] == "hard"
|
||||
assert len(data["objectives"]) == 1
|
||||
assert data["rewards"]["gold"] == 50
|
||||
assert data["lore_context"]["backstory"] == "Long ago..."
|
||||
|
||||
restored = Quest.from_dict(data)
|
||||
assert restored.quest_id == quest.quest_id
|
||||
assert restored.difficulty == quest.difficulty
|
||||
assert restored.rewards.gold == quest.rewards.gold
|
||||
assert restored.lore_context.backstory == quest.lore_context.backstory
|
||||
|
||||
def test_to_offer_dict(self):
|
||||
"""Test quest to_offer_dict for UI display."""
|
||||
quest = Quest(
|
||||
quest_id="quest_test",
|
||||
name="Test Quest",
|
||||
description="Test description",
|
||||
difficulty=QuestDifficulty.EASY,
|
||||
quest_giver_npc_ids=["npc_grom"],
|
||||
quest_giver_name="Grom",
|
||||
objectives=[
|
||||
QuestObjective(
|
||||
objective_id="obj1",
|
||||
description="Do something",
|
||||
objective_type=ObjectiveType.KILL,
|
||||
required_progress=5,
|
||||
)
|
||||
],
|
||||
rewards=QuestReward(gold=50, experience=100),
|
||||
)
|
||||
|
||||
offer_data = quest.to_offer_dict()
|
||||
assert offer_data["quest_id"] == "quest_test"
|
||||
assert offer_data["name"] == "Test Quest"
|
||||
assert offer_data["difficulty"] == "easy"
|
||||
assert offer_data["rewards"]["gold"] == 50
|
||||
assert len(offer_data["objectives"]) == 1
|
||||
|
||||
|
||||
class TestCharacterQuestState:
|
||||
"""Tests for CharacterQuestState dataclass."""
|
||||
|
||||
def test_create_quest_state(self):
|
||||
"""Test creating a quest state."""
|
||||
state = CharacterQuestState(
|
||||
quest_id="quest_test",
|
||||
status=QuestStatus.ACTIVE,
|
||||
accepted_at="2025-01-15T10:00:00Z",
|
||||
objectives_progress={"obj1": 3, "obj2": 0},
|
||||
)
|
||||
|
||||
assert state.quest_id == "quest_test"
|
||||
assert state.status == QuestStatus.ACTIVE
|
||||
assert state.get_progress("obj1") == 3
|
||||
assert state.get_progress("obj2") == 0
|
||||
assert state.get_progress("obj3") == 0 # Non-existent
|
||||
|
||||
def test_update_progress(self):
|
||||
"""Test updating objective progress."""
|
||||
state = CharacterQuestState(
|
||||
quest_id="quest_test",
|
||||
objectives_progress={"obj1": 0},
|
||||
)
|
||||
|
||||
state.update_progress("obj1", 5)
|
||||
assert state.get_progress("obj1") == 5
|
||||
|
||||
state.update_progress("obj1", 3)
|
||||
assert state.get_progress("obj1") == 8
|
||||
|
||||
def test_quest_state_serialization(self):
|
||||
"""Test quest state to_dict and from_dict."""
|
||||
state = CharacterQuestState(
|
||||
quest_id="quest_test",
|
||||
status=QuestStatus.COMPLETED,
|
||||
accepted_at="2025-01-15T10:00:00Z",
|
||||
completed_at="2025-01-16T14:30:00Z",
|
||||
objectives_progress={"obj1": 10},
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
assert data["quest_id"] == "quest_test"
|
||||
assert data["status"] == "completed"
|
||||
assert data["completed_at"] == "2025-01-16T14:30:00Z"
|
||||
|
||||
restored = CharacterQuestState.from_dict(data)
|
||||
assert restored.quest_id == state.quest_id
|
||||
assert restored.status == state.status
|
||||
assert restored.completed_at == state.completed_at
|
||||
Reference in New Issue
Block a user