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>
393 lines
13 KiB
Python
393 lines
13 KiB
Python
"""
|
|
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
|