Files
Code_of_Conquest/api/tests/test_quest_models.py
Phillip Tarrant df26abd207 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>
2025-11-29 15:42:55 -06:00

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