Compare commits

...

8 Commits

Author SHA1 Message Date
70b2b0f124 fixing leveling xp reporting 2025-11-30 18:20:40 -06:00
805d04cf4e Merge branch 'bugfix/combat-abilities-fix' into dev 2025-11-29 19:05:54 -06:00
f9e463bfc6 Root Cause
When using combat abilities (like "smite"), the web frontend was calling GET /api/v1/abilities/{ability_id} to fetch ability details for display, but this endpoint didn't exist, causing a 404 error.
  Additionally, after fixing that, the ability would execute but:

  1. Modal onclick issue: The onclick="closeModal()" on ability buttons was removing the button from the DOM before HTMX could fire the request
  2. Field name mismatch: The API returns mana_cost but the frontend expected mp_cost
  3. Duplicate text in combat log: The web view was adding "You" as actor and damage separately, but the API message already contained both
  4. Page not auto-refreshing: Various attempts to use HX-Trigger headers failed due to HTMX overwriting them

  Fixes Made

  1. Created /api/app/api/abilities.py - New abilities API endpoint with GET /api/v1/abilities and GET /api/v1/abilities/<ability_id>
  2. Modified /api/app/__init__.py - Registered the new abilities blueprint
  3. Modified /public_web/templates/game/partials/ability_modal.html - Changed onclick="closeModal()" to hx-on::before-request="closeModal()" so HTMX captures the request before modal closes
  4. Modified /public_web/app/views/combat_views.py:
    - Fixed mp_cost → mana_cost field name lookup
    - Removed duplicate actor/damage from combat log entries (API message is self-contained)
    - Added inline script to trigger page refresh after combat actions
  5. Modified /public_web/templates/game/combat.html - Updated JavaScript for combat action handling (though final fix was server-side script injection)
2025-11-29 19:05:39 -06:00
06ef8f6f0b Summary of Fixes
Issue 1: Slot Name Mismatch
  - Equipment modal used armor, accessory but API uses chest, accessory_1
  - Updated to all 8 API slots: weapon, off_hand, helmet, chest, gloves, boots, accessory_1, accessory_2

  Issue 2: HTMX Request Not Firing (the real blocker)
  - onclick=closeModal() was removing the button from DOM before HTMX could send the request
  - Changed to hx-on::after-request=closeModal() so modal closes after the request completes
2025-11-29 18:25:30 -06:00
72cf92021e fixes to make quest tracking work better, also quest rejection in via the converation with the NPC 2025-11-29 17:51:53 -06:00
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
e7e329e6ed Merge branch 'feat/Phase4-NPC-Shop' into dev 2025-11-29 01:17:01 -06:00
8bd494a52f NPC shop implimented 2025-11-29 01:16:46 -06:00
68 changed files with 13508 additions and 2276 deletions

View File

@@ -179,7 +179,21 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(inventory_bp) app.register_blueprint(inventory_bp)
logger.info("Inventory API blueprint registered") logger.info("Inventory API blueprint registered")
# Import and register Shop API blueprint
from app.api.shop import shop_bp
app.register_blueprint(shop_bp)
logger.info("Shop API blueprint registered")
# Import and register Quests API blueprint
from app.api.quests import quests_bp
app.register_blueprint(quests_bp)
logger.info("Quests API blueprint registered")
# Import and register Abilities API blueprint
from app.api.abilities import abilities_bp
app.register_blueprint(abilities_bp)
logger.info("Abilities API blueprint registered")
# TODO: Register additional blueprints as they are created # TODO: Register additional blueprints as they are created
# from app.api import marketplace, shop # from app.api import marketplace
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace') # app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')

View File

@@ -447,7 +447,10 @@ class NarrativeGenerator:
user_tier: UserTier, user_tier: UserTier,
npc_relationship: str | None = None, npc_relationship: str | None = None,
previous_dialogue: list[dict[str, Any]] | 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,
quest_ineligibility_context: dict[str, Any] | None = None,
player_asking_for_quests: bool = False
) -> NarrativeResponse: ) -> NarrativeResponse:
""" """
Generate NPC dialogue in response to player conversation. Generate NPC dialogue in response to player conversation.
@@ -461,6 +464,9 @@ class NarrativeGenerator:
npc_relationship: Optional description of relationship with NPC. npc_relationship: Optional description of relationship with NPC.
previous_dialogue: Optional list of previous exchanges. previous_dialogue: Optional list of previous exchanges.
npc_knowledge: Optional list of things this NPC knows about. npc_knowledge: Optional list of things this NPC knows about.
quest_offering_context: Optional quest offer context from QuestEligibilityService.
quest_ineligibility_context: Optional context explaining why player can't take a quest.
player_asking_for_quests: Whether the player is explicitly asking for quests/work.
Returns: Returns:
NarrativeResponse with NPC dialogue. NarrativeResponse with NPC dialogue.
@@ -500,6 +506,9 @@ class NarrativeGenerator:
npc_relationship=npc_relationship, npc_relationship=npc_relationship,
previous_dialogue=previous_dialogue or [], previous_dialogue=previous_dialogue or [],
npc_knowledge=npc_knowledge or [], npc_knowledge=npc_knowledge or [],
quest_offering_context=quest_offering_context,
quest_ineligibility_context=quest_ineligibility_context,
player_asking_for_quests=player_asking_for_quests,
max_tokens=model_config.max_tokens max_tokens=model_config.max_tokens
) )
except PromptTemplateError as e: 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) This module handles AI response parsing. Game state changes (items, gold, XP)
are now handled exclusively through predetermined dice check outcomes in are now handled exclusively through predetermined dice check outcomes in
action templates, not through AI-generated JSON. 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 dataclasses import dataclass, field
from typing import Any, Optional 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") changes.location_change = data.get("location_change")
return changes 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,107 @@ 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. Make it feel earned, like the NPC is opening up to someone they trust.
{% endif %} {% endif %}
{% if quest_offering_context and quest_offering_context.should_offer %}
## QUEST TO OFFER
The NPC has a quest to offer to the player.
**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 %}
{% if player_asking_for_quests %}
**CRITICAL: The player is explicitly asking for quests/work. You MUST offer this quest NOW.**
In your response:
1. Describe the quest situation naturally in your dialogue
2. End your response with the quest offer marker on its own line: [QUEST_OFFER:{{ quest_offering_context.quest_id }}]
The marker MUST appear - it triggers the UI to show accept/decline buttons.
{% else %}
**Quest Offering Guidelines:**
- Weave the quest naturally into conversation
- If the player shows interest, include the marker: [QUEST_OFFER:{{ quest_offering_context.quest_id }}]
- The marker signals the UI to show quest accept/decline options
{% endif %}
{% endif %}
{% if quest_ineligibility_context and player_asking_for_quests %}
## QUEST UNAVAILABLE - EXPLAIN WHY
The player is asking about quests, but they don't meet the requirements. Explain this in character.
{% if quest_ineligibility_context.reason_type == "level_too_low" %}
**Reason:** The player (level {{ quest_ineligibility_context.current_level }}) isn't experienced enough. They need to be level {{ quest_ineligibility_context.required_level }}.
**How to convey this:** The NPC should politely but firmly indicate the task is too dangerous for someone of their current skill level. Suggest they gain more experience first. Be encouraging but realistic - don't offer false hope.
**Example tone:** "I appreciate your enthusiasm, but this task requires someone with more experience. The bandits we're dealing with are seasoned fighters. Come back when you've proven yourself in a few more battles."
{% elif quest_ineligibility_context.reason_type == "level_too_high" %}
**Reason:** The player is too experienced for available tasks.
**How to convey this:** The NPC should indicate they have nothing worthy of such an accomplished adventurer right now.
{% elif quest_ineligibility_context.reason_type == "prerequisite_missing" %}
**Reason:** The player needs to complete other tasks first.
**How to convey this:** Hint that there's something else they should do first, or that circumstances aren't right yet.
{% elif quest_ineligibility_context.reason_type == "relationship_too_low" %}
**Reason:** The NPC doesn't trust the player enough yet.
**How to convey this:** Be guarded. Hint that you might have work, but you need to know you can trust them first.
{% elif quest_ineligibility_context.reason_type == "quest_already_active" %}
**Reason:** The player is already working on this quest.
**How to convey this:** Remind them they already accepted this task and should focus on completing it.
{% elif quest_ineligibility_context.reason_type == "quest_already_completed" %}
**Reason:** The player already completed this quest.
**How to convey this:** Thank them again for their previous help, mention you have nothing else right now.
{% elif quest_ineligibility_context.reason_type == "too_many_quests" %}
**Reason:** The player has too many active quests.
**How to convey this:** Suggest they finish some of their current commitments before taking on more.
{% else %}
**Reason:** {{ quest_ineligibility_context.message }}
**How to convey this:** Politely decline, staying in character.
{% endif %}
**IMPORTANT:** Do NOT offer the quest. Explain the situation naturally in dialogue.
{% 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 %} {% if npc.relationships %}
## NPC Relationships (for context) ## NPC Relationships (for context)
{% for rel in npc.relationships %} {% for rel in npc.relationships %}

129
api/app/api/abilities.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Abilities API Blueprint
This module provides API endpoints for fetching ability information:
- List all available abilities
- Get details for a specific ability
"""
from flask import Blueprint
from app.models.abilities import AbilityLoader
from app.utils.response import (
success_response,
not_found_response,
)
from app.utils.logging import get_logger
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
abilities_bp = Blueprint('abilities', __name__, url_prefix='/api/v1/abilities')
# Initialize ability loader (singleton pattern)
_ability_loader = None
def get_ability_loader() -> AbilityLoader:
"""
Get the singleton AbilityLoader instance.
Returns:
AbilityLoader: The ability loader instance
"""
global _ability_loader
if _ability_loader is None:
_ability_loader = AbilityLoader()
return _ability_loader
# =============================================================================
# Ability Endpoints
# =============================================================================
@abilities_bp.route('', methods=['GET'])
def list_abilities():
"""
List all available abilities.
Returns all abilities defined in the system with their full details.
Returns:
{
"abilities": [
{
"ability_id": "smite",
"name": "Smite",
"description": "Call down holy light...",
"ability_type": "spell",
"base_power": 20,
"damage_type": "holy",
"mana_cost": 10,
"cooldown": 0,
...
},
...
],
"count": 5
}
"""
logger.info("Listing all abilities")
loader = get_ability_loader()
abilities = loader.load_all_abilities()
# Convert to list of dicts for JSON serialization
abilities_list = [ability.to_dict() for ability in abilities.values()]
logger.info("Abilities listed", count=len(abilities_list))
return success_response({
"abilities": abilities_list,
"count": len(abilities_list)
})
@abilities_bp.route('/<ability_id>', methods=['GET'])
def get_ability(ability_id: str):
"""
Get details for a specific ability.
Args:
ability_id: The unique identifier for the ability (e.g., "smite")
Returns:
{
"ability_id": "smite",
"name": "Smite",
"description": "Call down holy light to smite your enemies",
"ability_type": "spell",
"base_power": 20,
"damage_type": "holy",
"scaling_stat": "wisdom",
"scaling_factor": 0.5,
"mana_cost": 10,
"cooldown": 0,
"effects_applied": [],
"is_aoe": false,
"target_count": 1
}
Errors:
404: Ability not found
"""
logger.info("Getting ability", ability_id=ability_id)
loader = get_ability_loader()
ability = loader.load_ability(ability_id)
if ability is None:
logger.warning("Ability not found", ability_id=ability_id)
return not_found_response(
message=f"Ability '{ability_id}' not found"
)
logger.info("Ability retrieved", ability_id=ability_id, name=ability.name)
return success_response(ability.to_dict())

View File

@@ -16,6 +16,7 @@ from app.services.session_service import get_session_service, SessionNotFound
from app.services.character_service import get_character_service, CharacterNotFound from app.services.character_service import get_character_service, CharacterNotFound
from app.services.npc_loader import get_npc_loader from app.services.npc_loader import get_npc_loader
from app.services.location_loader import get_location_loader from app.services.location_loader import get_location_loader
from app.services.quest_eligibility_service import get_quest_eligibility_service
from app.tasks.ai_tasks import enqueue_ai_task, TaskType from app.tasks.ai_tasks import enqueue_ai_task, TaskType
from app.utils.response import ( from app.utils.response import (
success_response, success_response,
@@ -192,6 +193,57 @@ def talk_to_npc(npc_id: str):
interaction interaction
) )
# Check for quest eligibility
quest_offering_context = None
quest_ineligibility_context = None # For explaining why player can't take a quest
player_asking_for_quests = _is_player_asking_for_quests(topic)
try:
quest_eligibility_service = get_quest_eligibility_service()
location_type = _get_location_type(session.game_state.current_location)
# If player is explicitly asking about quests, bypass probability roll
force_probability = 1.0 if player_asking_for_quests else None
eligibility_result = quest_eligibility_service.check_eligibility(
npc_id=npc_id,
character=character,
location_type=location_type,
location_id=session.game_state.current_location,
force_probability=force_probability
)
if eligibility_result.should_offer_quest and eligibility_result.selected_quest_context:
quest_offering_context = eligibility_result.selected_quest_context.to_dict()
# Add should_offer flag for template conditional check
quest_offering_context['should_offer'] = True
logger.debug(
"Quest eligible for offering",
npc_id=npc_id,
quest_id=quest_offering_context.get("quest_id"),
character_id=character.character_id
)
elif player_asking_for_quests and eligibility_result.blocking_reasons:
# Player asked for quests but isn't eligible - tell them why
quest_ineligibility_context = _build_ineligibility_context(
eligibility_result.blocking_reasons,
character.level,
npc_id
)
if quest_ineligibility_context:
logger.debug(
"Quest ineligible - providing reason to AI",
npc_id=npc_id,
reason=quest_ineligibility_context.get("reason_type"),
character_level=character.level
)
except Exception as e:
# Don't fail the conversation if quest eligibility check fails
logger.warning(
"Quest eligibility check failed",
npc_id=npc_id,
error=str(e)
)
# Build NPC knowledge for AI context # Build NPC knowledge for AI context
npc_knowledge = [] npc_knowledge = []
if npc.knowledge: if npc.knowledge:
@@ -220,6 +272,9 @@ def talk_to_npc(npc_id: str):
"interaction_count": interaction["interaction_count"], "interaction_count": interaction["interaction_count"],
"relationship_level": interaction.get("relationship_level", 50), "relationship_level": interaction.get("relationship_level", 50),
"previous_dialogue": previous_dialogue, # Pass conversation history "previous_dialogue": previous_dialogue, # Pass conversation history
"quest_offering_context": quest_offering_context, # Quest offer if eligible
"quest_ineligibility_context": quest_ineligibility_context, # Why player can't take quest
"player_asking_for_quests": player_asking_for_quests, # Player explicitly asking for work
} }
# Enqueue AI task # Enqueue AI task
@@ -428,3 +483,163 @@ def set_npc_flag(npc_id: str):
npc_id=npc_id, npc_id=npc_id,
error=str(e)) error=str(e))
return error_response("Failed to set flag", 500) return error_response("Failed to set flag", 500)
def _get_location_type(location_id: str) -> str:
"""
Extract location type from location_id for quest probability calculation.
Args:
location_id: The location identifier string
Returns:
Location type string (tavern, shop, wilderness, dungeon, or town)
"""
location_lower = location_id.lower()
if "tavern" in location_lower or "inn" in location_lower:
return "tavern"
elif "shop" in location_lower or "market" in location_lower or "store" in location_lower:
return "shop"
elif "wilderness" in location_lower or "forest" in location_lower or "road" in location_lower:
return "wilderness"
elif "dungeon" in location_lower or "cave" in location_lower or "mine" in location_lower:
return "dungeon"
return "town" # Default for town centers, squares, etc.
def _build_ineligibility_context(
blocking_reasons: dict[str, str],
character_level: int,
npc_id: str
) -> dict | None:
"""
Build context explaining why a player can't take a quest.
Parses the blocking reasons and creates a structured context
that the AI can use to explain to the player why they can't
help with the quest yet.
Args:
blocking_reasons: Dict of quest_id -> reason string
character_level: Player's current level
npc_id: The NPC they're talking to
Returns:
Dict with reason_type, message, and details, or None if no relevant reason
"""
if not blocking_reasons:
return None
# Look through blocking reasons for level-related issues
for quest_id, reason in blocking_reasons.items():
if "level too low" in reason.lower():
# Extract required level from reason string like "Character level too low (need 3)"
import re
match = re.search(r'need (\d+)', reason)
required_level = int(match.group(1)) if match else character_level + 1
return {
"reason_type": "level_too_low",
"current_level": character_level,
"required_level": required_level,
"message": f"The player is level {character_level} but needs to be level {required_level}",
"quest_id": quest_id,
}
if "level too high" in reason.lower():
return {
"reason_type": "level_too_high",
"current_level": character_level,
"message": "The player is too experienced for this task",
"quest_id": quest_id,
}
if "prerequisite" in reason.lower():
return {
"reason_type": "prerequisite_missing",
"message": "The player hasn't completed a required earlier task",
"quest_id": quest_id,
}
if "already active" in reason.lower():
return {
"reason_type": "quest_already_active",
"message": "The player is already working on this quest",
"quest_id": quest_id,
}
if "already completed" in reason.lower():
return {
"reason_type": "quest_already_completed",
"message": "The player has already completed this quest",
"quest_id": quest_id,
}
if "relationship" in reason.lower():
return {
"reason_type": "relationship_too_low",
"message": "The NPC doesn't trust the player enough yet",
"quest_id": quest_id,
}
if "max" in reason.lower() and "quest" in reason.lower():
return {
"reason_type": "too_many_quests",
"message": "The player already has too many active quests",
"quest_id": quest_id,
}
return None
def _is_player_asking_for_quests(topic: str) -> bool:
"""
Detect if the player is explicitly asking about quests or work.
This is used to bypass the probability roll when the player
clearly intends to find quests.
Args:
topic: The player's message/conversation topic
Returns:
True if player is asking about quests, False otherwise
"""
topic_lower = topic.lower()
# Quest-related keywords
quest_keywords = [
"quest",
"quests",
"any work",
"work for me",
"job",
"jobs",
"task",
"tasks",
"help you",
"help with",
"need help",
"anything i can do",
"can i help",
"how can i help",
"i'd love to help",
"i would love to help",
"want to help",
"like to help",
"offer my services",
"hire me",
"bounty",
"bounties",
"adventure",
"mission",
"missions",
]
for keyword in quest_keywords:
if keyword in topic_lower:
return True
return False

724
api/app/api/quests.py Normal file
View File

@@ -0,0 +1,724 @@
"""
Quest API endpoints for quest management.
This module provides REST endpoints for:
- Accepting offered quests
- Declining offered quests
- Getting quest details
- Listing character quests
- Completing quests (internal use)
"""
from datetime import datetime, timezone
from flask import Blueprint, request, jsonify
import structlog
from app.utils.response import api_response, error_response
from app.utils.auth import require_auth
from app.services.quest_service import get_quest_service
from app.services.quest_eligibility_service import get_quest_eligibility_service
from app.services.character_service import get_character_service
from app.models.quest import QuestStatus, CharacterQuestState
logger = structlog.get_logger(__name__)
quests_bp = Blueprint('quests', __name__, url_prefix='/api/v1/quests')
@quests_bp.route('/<quest_id>', methods=['GET'])
@require_auth
def get_quest(user_id: str, quest_id: str):
"""
Get details for a specific quest.
Args:
quest_id: Quest identifier
Returns:
Quest details or 404 if not found
"""
quest_service = get_quest_service()
quest = quest_service.load_quest(quest_id)
if not quest:
return error_response(
message=f"Quest not found: {quest_id}",
status=404,
code="QUEST_NOT_FOUND",
)
return api_response(
data=quest.to_offer_dict(),
message="Quest details retrieved",
)
@quests_bp.route('/accept', methods=['POST'])
@require_auth
def accept_quest(user_id: str):
"""
Accept an offered quest.
Expected JSON body:
{
"character_id": "char_123",
"quest_id": "quest_cellar_rats",
"npc_id": "npc_grom_ironbeard" # Optional: NPC who offered the quest
}
Returns:
Updated character quest state
"""
data = request.get_json()
if not data:
return error_response(
message="Request body is required",
status=400,
code="MISSING_BODY",
)
character_id = data.get('character_id')
quest_id = data.get('quest_id')
npc_id = data.get('npc_id')
if not character_id or not quest_id:
return error_response(
message="character_id and quest_id are required",
status=400,
code="MISSING_FIELDS",
)
# Get character
character_service = get_character_service()
character = character_service.get_character(character_id)
if not character:
return error_response(
message=f"Character not found: {character_id}",
status=404,
code="CHARACTER_NOT_FOUND",
)
# Verify character belongs to user
if character.user_id != user_id:
return error_response(
message="Character does not belong to this user",
status=403,
code="ACCESS_DENIED",
)
# Get quest
quest_service = get_quest_service()
quest = quest_service.load_quest(quest_id)
if not quest:
return error_response(
message=f"Quest not found: {quest_id}",
status=404,
code="QUEST_NOT_FOUND",
)
# Check if already at max quests
if len(character.active_quests) >= 2:
return error_response(
message="Maximum active quests reached (2)",
status=400,
code="MAX_QUESTS_REACHED",
)
# Check if quest is already active
if quest_id in character.active_quests:
return error_response(
message="Quest is already active",
status=400,
code="QUEST_ALREADY_ACTIVE",
)
# Check if quest is already completed
completed_quests = getattr(character, 'completed_quests', [])
if quest_id in completed_quests:
return error_response(
message="Quest has already been completed",
status=400,
code="QUEST_ALREADY_COMPLETED",
)
# Add quest to active quests
character.active_quests.append(quest_id)
# Create quest state tracking
quest_state = CharacterQuestState(
quest_id=quest_id,
status=QuestStatus.ACTIVE,
accepted_at=datetime.now(timezone.utc).isoformat(),
objectives_progress={
obj.objective_id: 0 for obj in quest.objectives
},
)
# Store quest state in character (would normally go to database)
if not hasattr(character, 'quest_states'):
character.quest_states = {}
character.quest_states[quest_id] = quest_state.to_dict()
# Update NPC relationship if NPC provided
if npc_id and npc_id in character.npc_interactions:
npc_interaction = character.npc_interactions[npc_id]
current_relationship = npc_interaction.get('relationship_level', 50)
npc_interaction['relationship_level'] = min(100, current_relationship + 5)
# Set accepted flag
if 'custom_flags' not in npc_interaction:
npc_interaction['custom_flags'] = {}
npc_interaction['custom_flags'][f'accepted_{quest_id}'] = True
# Save character
character_service.update_character(character)
logger.info(
"Quest accepted",
character_id=character_id,
quest_id=quest_id,
npc_id=npc_id,
)
return api_response(
data={
"quest_id": quest_id,
"quest_name": quest.name,
"active_quests": character.active_quests,
"quest_state": quest_state.to_dict(),
},
message=f"Quest accepted: {quest.name}",
)
@quests_bp.route('/decline', methods=['POST'])
@require_auth
def decline_quest(user_id: str):
"""
Decline an offered quest.
Sets a flag on the NPC interaction to prevent immediate re-offering.
Expected JSON body:
{
"character_id": "char_123",
"quest_id": "quest_cellar_rats",
"npc_id": "npc_grom_ironbeard"
}
Returns:
Confirmation of decline
"""
data = request.get_json()
if not data:
return error_response(
message="Request body is required",
status=400,
code="MISSING_BODY",
)
character_id = data.get('character_id')
quest_id = data.get('quest_id')
npc_id = data.get('npc_id')
if not character_id or not quest_id:
return error_response(
message="character_id and quest_id are required",
status=400,
code="MISSING_FIELDS",
)
# Get character
character_service = get_character_service()
character = character_service.get_character(character_id)
if not character:
return error_response(
message=f"Character not found: {character_id}",
status=404,
code="CHARACTER_NOT_FOUND",
)
# Verify character belongs to user
if character.user_id != user_id:
return error_response(
message="Character does not belong to this user",
status=403,
code="ACCESS_DENIED",
)
# Set declined flag on NPC interaction
if npc_id:
if npc_id not in character.npc_interactions:
character.npc_interactions[npc_id] = {
'npc_id': npc_id,
'relationship_level': 50,
'custom_flags': {},
}
npc_interaction = character.npc_interactions[npc_id]
if 'custom_flags' not in npc_interaction:
npc_interaction['custom_flags'] = {}
# Set declined flag - this will be checked by quest eligibility
npc_interaction['custom_flags'][f'declined_{quest_id}'] = True
# Save character
character_service.update_character(character)
logger.info(
"Quest declined",
character_id=character_id,
quest_id=quest_id,
npc_id=npc_id,
)
return api_response(
data={
"quest_id": quest_id,
"declined": True,
},
message="Quest declined",
)
@quests_bp.route('/characters/<character_id>/quests', methods=['GET'])
@require_auth
def get_character_quests(user_id: str, character_id: str):
"""
Get a character's active and completed quests.
Args:
character_id: Character identifier
Returns:
Lists of active and completed quests with details
"""
# Get character
character_service = get_character_service()
character = character_service.get_character(character_id)
if not character:
return error_response(
message=f"Character not found: {character_id}",
status=404,
code="CHARACTER_NOT_FOUND",
)
# Verify character belongs to user
if character.user_id != user_id:
return error_response(
message="Character does not belong to this user",
status=403,
code="ACCESS_DENIED",
)
# Get quest details for active quests
quest_service = get_quest_service()
active_quests = []
for quest_id in character.active_quests:
quest = quest_service.load_quest(quest_id)
if quest:
quest_data = quest.to_offer_dict()
# Add progress if available
quest_states = getattr(character, 'quest_states', {})
if quest_id in quest_states:
quest_data['progress'] = quest_states[quest_id]
active_quests.append(quest_data)
# Get completed quest IDs
completed_quests = getattr(character, 'completed_quests', [])
return api_response(
data={
"active_quests": active_quests,
"completed_quest_ids": completed_quests,
"active_count": len(active_quests),
"completed_count": len(completed_quests),
},
message="Character quests retrieved",
)
@quests_bp.route('/complete', methods=['POST'])
@require_auth
def complete_quest(user_id: str):
"""
Complete a quest and grant rewards.
This endpoint is typically called by the game system when all
objectives are completed, not directly by the player.
Expected JSON body:
{
"character_id": "char_123",
"quest_id": "quest_cellar_rats",
"npc_id": "npc_grom_ironbeard" # Optional: for reward dialogue
}
Returns:
Rewards granted and updated character state
"""
data = request.get_json()
if not data:
return error_response(
message="Request body is required",
status=400,
code="MISSING_BODY",
)
character_id = data.get('character_id')
quest_id = data.get('quest_id')
npc_id = data.get('npc_id')
if not character_id or not quest_id:
return error_response(
message="character_id and quest_id are required",
status=400,
code="MISSING_FIELDS",
)
# Get character
character_service = get_character_service()
character = character_service.get_character(character_id)
if not character:
return error_response(
message=f"Character not found: {character_id}",
status=404,
code="CHARACTER_NOT_FOUND",
)
# Verify character belongs to user
if character.user_id != user_id:
return error_response(
message="Character does not belong to this user",
status=403,
code="ACCESS_DENIED",
)
# Check quest is active
if quest_id not in character.active_quests:
return error_response(
message="Quest is not active for this character",
status=400,
code="QUEST_NOT_ACTIVE",
)
# Get quest for rewards
quest_service = get_quest_service()
quest = quest_service.load_quest(quest_id)
if not quest:
return error_response(
message=f"Quest not found: {quest_id}",
status=404,
code="QUEST_NOT_FOUND",
)
# Remove from active quests
character.active_quests.remove(quest_id)
# Add to completed quests
if not hasattr(character, 'completed_quests'):
character.completed_quests = []
character.completed_quests.append(quest_id)
# Grant rewards
rewards = quest.rewards
character.gold += rewards.gold
leveled_up = character.add_experience(rewards.experience)
# Apply relationship bonuses
for bonus_npc_id, bonus_amount in rewards.relationship_bonuses.items():
if bonus_npc_id in character.npc_interactions:
npc_interaction = character.npc_interactions[bonus_npc_id]
current_relationship = npc_interaction.get('relationship_level', 50)
npc_interaction['relationship_level'] = min(100, current_relationship + bonus_amount)
# Reveal locations
for location_id in rewards.reveals_locations:
if location_id not in character.discovered_locations:
character.discovered_locations.append(location_id)
# Update quest state
quest_states = getattr(character, 'quest_states', {})
if quest_id in quest_states:
quest_states[quest_id]['status'] = QuestStatus.COMPLETED.value
quest_states[quest_id]['completed_at'] = datetime.now(timezone.utc).isoformat()
# Save character
character_service.update_character(character)
# Get completion dialogue if NPC provided
completion_dialogue = ""
if npc_id:
completion_dialogue = quest.get_completion_dialogue(npc_id)
logger.info(
"Quest completed",
character_id=character_id,
quest_id=quest_id,
gold_granted=rewards.gold,
xp_granted=rewards.experience,
leveled_up=leveled_up,
)
return api_response(
data={
"quest_id": quest_id,
"quest_name": quest.name,
"rewards": {
"gold": rewards.gold,
"experience": rewards.experience,
"items": rewards.items,
"relationship_bonuses": rewards.relationship_bonuses,
"reveals_locations": rewards.reveals_locations,
},
"leveled_up": leveled_up,
"new_level": character.level if leveled_up else None,
"completion_dialogue": completion_dialogue,
},
message=f"Quest completed: {quest.name}",
)
@quests_bp.route('/progress', methods=['POST'])
@require_auth
def update_quest_progress(user_id: str):
"""
Update progress on a quest objective.
This endpoint is called when a player completes actions that contribute
to quest objectives (kills enemies, collects items, etc.).
Expected JSON body:
{
"character_id": "char_123",
"quest_id": "quest_cellar_rats",
"objective_id": "kill_rats",
"amount": 1 # Optional, defaults to 1
}
Returns:
Updated progress state with completion flags
"""
data = request.get_json()
if not data:
return error_response(
message="Request body is required",
status=400,
code="MISSING_BODY",
)
character_id = data.get('character_id')
quest_id = data.get('quest_id')
objective_id = data.get('objective_id')
amount = data.get('amount', 1)
if not character_id or not quest_id or not objective_id:
return error_response(
message="character_id, quest_id, and objective_id are required",
status=400,
code="MISSING_FIELDS",
)
# Get character
character_service = get_character_service()
character = character_service.get_character(character_id)
if not character:
return error_response(
message=f"Character not found: {character_id}",
status=404,
code="CHARACTER_NOT_FOUND",
)
# Verify character belongs to user
if character.user_id != user_id:
return error_response(
message="Character does not belong to this user",
status=403,
code="ACCESS_DENIED",
)
# Check quest is active
if quest_id not in character.active_quests:
return error_response(
message="Quest is not active for this character",
status=400,
code="QUEST_NOT_ACTIVE",
)
# Get quest definition to validate objective and get required amount
quest_service = get_quest_service()
quest = quest_service.load_quest(quest_id)
if not quest:
return error_response(
message=f"Quest not found: {quest_id}",
status=404,
code="QUEST_NOT_FOUND",
)
# Find the objective
objective = None
for obj in quest.objectives:
if obj.objective_id == objective_id:
objective = obj
break
if not objective:
return error_response(
message=f"Objective not found: {objective_id}",
status=404,
code="OBJECTIVE_NOT_FOUND",
)
# Get or create quest state
quest_states = getattr(character, 'quest_states', {})
if quest_id not in quest_states:
# Initialize quest state if missing
quest_states[quest_id] = {
'quest_id': quest_id,
'status': QuestStatus.ACTIVE.value,
'accepted_at': datetime.now(timezone.utc).isoformat(),
'objectives_progress': {obj.objective_id: 0 for obj in quest.objectives},
'completed_at': None,
}
character.quest_states = quest_states
quest_state = quest_states[quest_id]
objectives_progress = quest_state.get('objectives_progress', {})
# Update progress
current_progress = objectives_progress.get(objective_id, 0)
new_progress = min(current_progress + amount, objective.required_progress)
objectives_progress[objective_id] = new_progress
quest_state['objectives_progress'] = objectives_progress
# Check if this objective is complete
objective_complete = new_progress >= objective.required_progress
# Check if entire quest is complete (all objectives met)
quest_complete = True
for obj in quest.objectives:
obj_progress = objectives_progress.get(obj.objective_id, 0)
if obj_progress < obj.required_progress:
quest_complete = False
break
# Save character
character_service.update_character(character)
logger.info(
"Quest progress updated",
character_id=character_id,
quest_id=quest_id,
objective_id=objective_id,
new_progress=new_progress,
required=objective.required_progress,
objective_complete=objective_complete,
quest_complete=quest_complete,
)
return api_response(
data={
"quest_id": quest_id,
"objective_id": objective_id,
"new_progress": new_progress,
"required": objective.required_progress,
"objective_complete": objective_complete,
"quest_complete": quest_complete,
"all_progress": objectives_progress,
},
message=f"Progress updated: {new_progress}/{objective.required_progress}",
)
@quests_bp.route('/abandon', methods=['POST'])
@require_auth
def abandon_quest(user_id: str):
"""
Abandon an active quest.
Expected JSON body:
{
"character_id": "char_123",
"quest_id": "quest_cellar_rats"
}
Returns:
Confirmation of abandonment
"""
data = request.get_json()
if not data:
return error_response(
message="Request body is required",
status=400,
code="MISSING_BODY",
)
character_id = data.get('character_id')
quest_id = data.get('quest_id')
if not character_id or not quest_id:
return error_response(
message="character_id and quest_id are required",
status=400,
code="MISSING_FIELDS",
)
# Get character
character_service = get_character_service()
character = character_service.get_character(character_id)
if not character:
return error_response(
message=f"Character not found: {character_id}",
status=404,
code="CHARACTER_NOT_FOUND",
)
# Verify character belongs to user
if character.user_id != user_id:
return error_response(
message="Character does not belong to this user",
status=403,
code="ACCESS_DENIED",
)
# Check quest is active
if quest_id not in character.active_quests:
return error_response(
message="Quest is not active for this character",
status=400,
code="QUEST_NOT_ACTIVE",
)
# Remove from active quests
character.active_quests.remove(quest_id)
# Update quest state
quest_states = getattr(character, 'quest_states', {})
if quest_id in quest_states:
quest_states[quest_id]['status'] = QuestStatus.FAILED.value
# Save character
character_service.update_character(character)
logger.info(
"Quest abandoned",
character_id=character_id,
quest_id=quest_id,
)
return api_response(
data={
"quest_id": quest_id,
"abandoned": True,
"active_quests": character.active_quests,
},
message="Quest abandoned",
)

474
api/app/api/shop.py Normal file
View File

@@ -0,0 +1,474 @@
"""
Shop API Blueprint
Endpoints for browsing NPC shop inventory and making purchases/sales.
All endpoints require authentication.
Endpoints:
- GET /api/v1/shop/<shop_id>/inventory - Get shop inventory with character context
- POST /api/v1/shop/<shop_id>/purchase - Purchase an item
- POST /api/v1/shop/<shop_id>/sell - Sell an item back to the shop
"""
from flask import Blueprint, request
from app.services.shop_service import (
get_shop_service,
ShopNotFoundError,
ItemNotInShopError,
InsufficientGoldError,
ItemNotOwnedError,
)
from app.services.character_service import (
get_character_service,
CharacterNotFound,
)
from app.utils.response import (
success_response,
error_response,
not_found_response,
validation_error_response,
)
from app.utils.auth import require_auth, get_current_user
from app.utils.logging import get_logger
logger = get_logger(__file__)
shop_bp = Blueprint('shop', __name__)
# =============================================================================
# API Endpoints
# =============================================================================
@shop_bp.route('/api/v1/shop/<shop_id>/inventory', methods=['GET'])
@require_auth
def get_shop_inventory(shop_id: str):
"""
Get shop inventory with character context.
Query Parameters:
character_id: Character ID to use for gold/affordability checks
Args:
shop_id: Shop identifier
Returns:
200: Shop inventory with enriched item data
401: Not authenticated
404: Shop or character not found
422: Validation error (missing character_id)
500: Internal server error
Example Response:
{
"result": {
"shop": {
"shop_id": "general_store",
"shop_name": "General Store",
"shopkeeper_name": "Merchant Guildmaster",
"sell_rate": 0.5
},
"character": {
"character_id": "char_abc",
"gold": 500
},
"inventory": [
{
"item": {...},
"shop_price": 25,
"stock": -1,
"can_afford": true,
"item_id": "health_potion_small"
}
],
"categories": ["consumable", "weapon", "armor"]
}
}
"""
try:
user = get_current_user()
# Get character_id from query params
character_id = request.args.get('character_id', '').strip()
if not character_id:
return validation_error_response(
message="Validation failed",
details={"character_id": "character_id query parameter is required"}
)
logger.info(
"Getting shop inventory",
user_id=user.id,
shop_id=shop_id,
character_id=character_id
)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get enriched shop inventory
shop_service = get_shop_service()
result = shop_service.get_shop_inventory_for_character(shop_id, character)
logger.info(
"Shop inventory retrieved",
user_id=user.id,
shop_id=shop_id,
item_count=len(result["inventory"])
)
return success_response(result=result)
except CharacterNotFound as e:
logger.warning(
"Character not found for shop",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id if 'character_id' in locals() else 'unknown',
error=str(e)
)
return not_found_response(message=str(e))
except ShopNotFoundError as e:
logger.warning(
"Shop not found",
shop_id=shop_id,
error=str(e)
)
return not_found_response(message=str(e))
except Exception as e:
logger.error(
"Failed to get shop inventory",
user_id=user.id if 'user' in locals() else 'unknown',
shop_id=shop_id,
error=str(e)
)
return error_response(
code="SHOP_INVENTORY_ERROR",
message="Failed to retrieve shop inventory",
status=500
)
@shop_bp.route('/api/v1/shop/<shop_id>/purchase', methods=['POST'])
@require_auth
def purchase_item(shop_id: str):
"""
Purchase an item from the shop.
Args:
shop_id: Shop identifier
Request Body:
{
"character_id": "char_abc",
"item_id": "health_potion_small",
"quantity": 1,
"session_id": "optional_session_id"
}
Returns:
200: Purchase successful
400: Insufficient gold or invalid quantity
401: Not authenticated
404: Shop, character, or item not found
422: Validation error
500: Internal server error
Example Response:
{
"result": {
"purchase": {
"item_id": "health_potion_small",
"quantity": 2,
"total_cost": 50
},
"character": {
"character_id": "char_abc",
"gold": 450
},
"items_added": ["health_potion_small_abc123", "health_potion_small_def456"]
}
}
"""
try:
user = get_current_user()
# Get request data
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"error": "Request body is required"}
)
character_id = data.get('character_id', '').strip()
item_id = data.get('item_id', '').strip()
quantity = data.get('quantity', 1)
session_id = data.get('session_id', '').strip() or None
# Validate required fields
validation_errors = {}
if not character_id:
validation_errors['character_id'] = "character_id is required"
if not item_id:
validation_errors['item_id'] = "item_id is required"
if not isinstance(quantity, int) or quantity < 1:
validation_errors['quantity'] = "quantity must be a positive integer"
if validation_errors:
return validation_error_response(
message="Validation failed",
details=validation_errors
)
logger.info(
"Processing purchase",
user_id=user.id,
shop_id=shop_id,
character_id=character_id,
item_id=item_id,
quantity=quantity
)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Process purchase
shop_service = get_shop_service()
result = shop_service.purchase_item(
character=character,
shop_id=shop_id,
item_id=item_id,
quantity=quantity,
session_id=session_id
)
# Save updated character
char_service.update_character(character)
logger.info(
"Purchase completed",
user_id=user.id,
shop_id=shop_id,
character_id=character_id,
item_id=item_id,
quantity=quantity,
total_cost=result["purchase"]["total_cost"]
)
return success_response(result=result)
except CharacterNotFound as e:
logger.warning(
"Character not found for purchase",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id if 'character_id' in locals() else 'unknown',
error=str(e)
)
return not_found_response(message=str(e))
except ShopNotFoundError as e:
logger.warning(
"Shop not found for purchase",
shop_id=shop_id,
error=str(e)
)
return not_found_response(message=str(e))
except ItemNotInShopError as e:
logger.warning(
"Item not in shop",
shop_id=shop_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e)
)
return not_found_response(message=str(e))
except InsufficientGoldError as e:
logger.warning(
"Insufficient gold for purchase",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id if 'character_id' in locals() else 'unknown',
error=str(e)
)
return error_response(
code="INSUFFICIENT_GOLD",
message=str(e),
status=400
)
except Exception as e:
logger.error(
"Failed to process purchase",
user_id=user.id if 'user' in locals() else 'unknown',
shop_id=shop_id,
error=str(e)
)
return error_response(
code="PURCHASE_ERROR",
message="Failed to process purchase",
status=500
)
@shop_bp.route('/api/v1/shop/<shop_id>/sell', methods=['POST'])
@require_auth
def sell_item(shop_id: str):
"""
Sell an item back to the shop.
Args:
shop_id: Shop identifier
Request Body:
{
"character_id": "char_abc",
"item_instance_id": "health_potion_small_abc123",
"quantity": 1,
"session_id": "optional_session_id"
}
Returns:
200: Sale successful
401: Not authenticated
404: Shop, character, or item not found
422: Validation error
500: Internal server error
Example Response:
{
"result": {
"sale": {
"item_id": "health_potion_small_abc123",
"item_name": "Small Health Potion",
"quantity": 1,
"total_earned": 12
},
"character": {
"character_id": "char_abc",
"gold": 512
}
}
}
"""
try:
user = get_current_user()
# Get request data
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"error": "Request body is required"}
)
character_id = data.get('character_id', '').strip()
item_instance_id = data.get('item_instance_id', '').strip()
quantity = data.get('quantity', 1)
session_id = data.get('session_id', '').strip() or None
# Validate required fields
validation_errors = {}
if not character_id:
validation_errors['character_id'] = "character_id is required"
if not item_instance_id:
validation_errors['item_instance_id'] = "item_instance_id is required"
if not isinstance(quantity, int) or quantity < 1:
validation_errors['quantity'] = "quantity must be a positive integer"
if validation_errors:
return validation_error_response(
message="Validation failed",
details=validation_errors
)
logger.info(
"Processing sale",
user_id=user.id,
shop_id=shop_id,
character_id=character_id,
item_instance_id=item_instance_id,
quantity=quantity
)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Process sale
shop_service = get_shop_service()
result = shop_service.sell_item(
character=character,
shop_id=shop_id,
item_instance_id=item_instance_id,
quantity=quantity,
session_id=session_id
)
# Save updated character
char_service.update_character(character)
logger.info(
"Sale completed",
user_id=user.id,
shop_id=shop_id,
character_id=character_id,
item_instance_id=item_instance_id,
total_earned=result["sale"]["total_earned"]
)
return success_response(result=result)
except CharacterNotFound as e:
logger.warning(
"Character not found for sale",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id if 'character_id' in locals() else 'unknown',
error=str(e)
)
return not_found_response(message=str(e))
except ShopNotFoundError as e:
logger.warning(
"Shop not found for sale",
shop_id=shop_id,
error=str(e)
)
return not_found_response(message=str(e))
except ItemNotOwnedError as e:
logger.warning(
"Item not owned for sale",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id if 'character_id' in locals() else 'unknown',
item_instance_id=item_instance_id if 'item_instance_id' in locals() else 'unknown',
error=str(e)
)
return not_found_response(message=str(e))
except Exception as e:
logger.error(
"Failed to process sale",
user_id=user.id if 'user' in locals() else 'unknown',
shop_id=shop_id,
error=str(e)
)
return error_response(
code="SALE_ERROR",
message="Failed to process sale",
status=500
)

View File

@@ -0,0 +1,246 @@
# Base Shield Templates for Procedural Generation
#
# These templates define the foundation that affixes attach to.
# Example: "Wooden Shield" + "Reinforced" prefix = "Reinforced Wooden Shield"
#
# Shield categories:
# - Light Shields: Low defense, high mobility (bucklers)
# - Medium Shields: Balanced defense/weight (round shields, kite shields)
# - Heavy Shields: High defense, reduced mobility (tower shields)
# - Magical Shields: Low physical defense, high resistance (arcane aegis)
#
# Template Structure:
# template_id: Unique identifier
# name: Base item name
# item_type: "armor" (shields use armor type)
# slot: "off_hand" (shields occupy off-hand slot)
# description: Flavor text
# base_defense: Physical damage reduction
# base_resistance: Magical damage reduction
# base_value: Gold value
# required_level: Min level to use/drop
# drop_weight: Higher = more common (1.0 = standard)
# min_rarity: Minimum rarity for this template
# block_chance: Chance to fully block an attack (optional)
shields:
# ==================== LIGHT SHIELDS (HIGH MOBILITY) ====================
buckler:
template_id: "buckler"
name: "Buckler"
item_type: "armor"
slot: "off_hand"
description: "A small, round shield strapped to the forearm. Offers minimal protection but doesn't hinder movement."
base_defense: 3
base_resistance: 0
base_value: 20
required_level: 1
drop_weight: 1.5
block_chance: 0.05
parrying_buckler:
template_id: "parrying_buckler"
name: "Parrying Buckler"
item_type: "armor"
slot: "off_hand"
description: "A lightweight buckler with a reinforced edge, designed for deflecting blows."
base_defense: 4
base_resistance: 0
base_value: 45
required_level: 3
drop_weight: 1.0
block_chance: 0.08
# ==================== MEDIUM SHIELDS (BALANCED) ====================
wooden_shield:
template_id: "wooden_shield"
name: "Wooden Shield"
item_type: "armor"
slot: "off_hand"
description: "A basic shield carved from sturdy oak. Reliable protection for new adventurers."
base_defense: 5
base_resistance: 1
base_value: 35
required_level: 1
drop_weight: 1.3
round_shield:
template_id: "round_shield"
name: "Round Shield"
item_type: "armor"
slot: "off_hand"
description: "A circular shield with an iron boss. Popular among infantry and mercenaries."
base_defense: 7
base_resistance: 1
base_value: 50
required_level: 2
drop_weight: 1.2
iron_shield:
template_id: "iron_shield"
name: "Iron Shield"
item_type: "armor"
slot: "off_hand"
description: "A solid iron shield. Heavy but dependable."
base_defense: 9
base_resistance: 1
base_value: 70
required_level: 3
drop_weight: 1.0
kite_shield:
template_id: "kite_shield"
name: "Kite Shield"
item_type: "armor"
slot: "off_hand"
description: "A tall, tapered shield that protects the entire body. Favored by knights."
base_defense: 10
base_resistance: 2
base_value: 80
required_level: 3
drop_weight: 1.0
heater_shield:
template_id: "heater_shield"
name: "Heater Shield"
item_type: "armor"
slot: "off_hand"
description: "A classic triangular shield with excellent balance of protection and mobility."
base_defense: 12
base_resistance: 2
base_value: 100
required_level: 4
drop_weight: 0.9
steel_shield:
template_id: "steel_shield"
name: "Steel Shield"
item_type: "armor"
slot: "off_hand"
description: "A finely crafted steel shield. Durable and well-balanced."
base_defense: 14
base_resistance: 2
base_value: 125
required_level: 5
drop_weight: 0.8
min_rarity: "uncommon"
# ==================== HEAVY SHIELDS (MAXIMUM DEFENSE) ====================
tower_shield:
template_id: "tower_shield"
name: "Tower Shield"
item_type: "armor"
slot: "off_hand"
description: "A massive shield that covers the entire body. Extremely heavy but offers unparalleled protection."
base_defense: 18
base_resistance: 3
base_value: 175
required_level: 6
drop_weight: 0.6
min_rarity: "uncommon"
block_chance: 0.12
fortified_tower_shield:
template_id: "fortified_tower_shield"
name: "Fortified Tower Shield"
item_type: "armor"
slot: "off_hand"
description: "A reinforced tower shield with iron plating. A mobile wall on the battlefield."
base_defense: 22
base_resistance: 4
base_value: 250
required_level: 7
drop_weight: 0.4
min_rarity: "rare"
block_chance: 0.15
# ==================== MAGICAL SHIELDS (HIGH RESISTANCE) ====================
enchanted_buckler:
template_id: "enchanted_buckler"
name: "Enchanted Buckler"
item_type: "armor"
slot: "off_hand"
description: "A small shield inscribed with protective runes. Weak against physical attacks but excellent against magic."
base_defense: 2
base_resistance: 6
base_value: 80
required_level: 3
drop_weight: 0.8
warded_shield:
template_id: "warded_shield"
name: "Warded Shield"
item_type: "armor"
slot: "off_hand"
description: "A shield covered in magical wards that deflect spells."
base_defense: 4
base_resistance: 8
base_value: 110
required_level: 4
drop_weight: 0.7
magical_aegis:
template_id: "magical_aegis"
name: "Magical Aegis"
item_type: "armor"
slot: "off_hand"
description: "An arcane shield that shimmers with protective energy. Prized by battle mages."
base_defense: 8
base_resistance: 10
base_value: 150
required_level: 5
drop_weight: 0.6
min_rarity: "uncommon"
spellguard:
template_id: "spellguard"
name: "Spellguard"
item_type: "armor"
slot: "off_hand"
description: "A crystalline shield forged from condensed magical energy. Near-impervious to spells."
base_defense: 6
base_resistance: 14
base_value: 200
required_level: 6
drop_weight: 0.5
min_rarity: "rare"
# ==================== SPECIALIZED SHIELDS ====================
spiked_shield:
template_id: "spiked_shield"
name: "Spiked Shield"
item_type: "armor"
slot: "off_hand"
description: "A shield with iron spikes. Can be used offensively in close combat."
base_defense: 8
base_resistance: 1
base_value: 90
required_level: 4
drop_weight: 0.8
base_damage: 5
lantern_shield:
template_id: "lantern_shield"
name: "Lantern Shield"
item_type: "armor"
slot: "off_hand"
description: "A peculiar shield with an attached lantern. Useful for dungeon exploration."
base_defense: 6
base_resistance: 1
base_value: 75
required_level: 3
drop_weight: 0.7
provides_light: true
pavise:
template_id: "pavise"
name: "Pavise"
item_type: "armor"
slot: "off_hand"
description: "A large rectangular shield that can be planted in the ground for cover. Favored by crossbowmen."
base_defense: 15
base_resistance: 2
base_value: 130
required_level: 5
drop_weight: 0.6
deployable: true

View File

@@ -81,9 +81,8 @@ dialogue_hooks:
busy: "*keeps hammering* Talk while I work. Time is iron." busy: "*keeps hammering* Talk while I work. Time is iron."
quest_complete: "*nods approvingly* Fine work. You've got the heart of a warrior." quest_complete: "*nods approvingly* Fine work. You've got the heart of a warrior."
quest_giver_for: # Note: Quest offerings are now defined in quest YAML files (quest-centric design)
- quest_ore_delivery # See /api/app/data/quests/ for quest definitions that reference this NPC
- quest_equipment_repair
reveals_locations: [] reveals_locations: []

View File

@@ -83,8 +83,8 @@ dialogue_hooks:
busy: "Got thirsty folk to serve. Make it quick." busy: "Got thirsty folk to serve. Make it quick."
quest_complete: "*actually smiles* Well done, lad. Drink's on the house." quest_complete: "*actually smiles* Well done, lad. Drink's on the house."
quest_giver_for: # Note: Quest offerings are now defined in quest YAML files (quest-centric design)
- quest_cellar_rats # See /api/app/data/quests/ for quest definitions that reference this NPC
reveals_locations: reveals_locations:
- crossville_dungeon - crossville_dungeon

View File

@@ -13,15 +13,11 @@ personality:
- increasingly desperate - increasingly desperate
- hiding something significant - hiding something significant
speech_style: | speech_style: |
Speaks with the practiced cadence of a politician - measured words, Speaks with the practiced cadence of a politician - measured words.
careful pauses for effect. His voice wavers slightly when stressed, His voice wavers slightly when stressed, has a habit of clearing his
and he has a habit of clearing his throat before difficult topics. throat before difficult topics. Uses formal address even in casual conversation.
Uses formal address even in casual conversation.
quirks: quirks:
- Constantly adjusts his mayoral chain of office
- Glances at his manor when the Old Mines are mentioned - Glances at his manor when the Old Mines are mentioned
- Keeps touching a ring on his left hand
- Offers wine to guests but never drinks himself
appearance: appearance:
brief: Tall, thin man with receding grey hair, worry lines, and expensive but slightly disheveled clothing brief: Tall, thin man with receding grey hair, worry lines, and expensive but slightly disheveled clothing
@@ -71,9 +67,8 @@ dialogue_hooks:
busy: "*distracted* I have urgent matters to attend. Perhaps later?" busy: "*distracted* I have urgent matters to attend. Perhaps later?"
quest_complete: "*genuine relief* You have done Crossville a great service." quest_complete: "*genuine relief* You have done Crossville a great service."
quest_giver_for: # Note: Quest offerings are now defined in quest YAML files (quest-centric design)
- quest_mayors_request # See /api/app/data/quests/ for quest definitions that reference this NPC
- quest_bandit_threat
reveals_locations: reveals_locations:
- crossville_dungeon - crossville_dungeon

View File

@@ -76,8 +76,8 @@ dialogue_hooks:
busy: "*glances at the door* Not now. Later." busy: "*glances at the door* Not now. Later."
quest_complete: "*grins* You've got potential. Stick around." quest_complete: "*grins* You've got potential. Stick around."
quest_giver_for: # Note: Quest offerings are now defined in quest YAML files (quest-centric design)
- quest_bandit_camp # See /api/app/data/quests/ for quest definitions that reference this NPC
reveals_locations: reveals_locations:
- crossville_forest - crossville_forest

View File

@@ -0,0 +1,109 @@
# Cellar Rats - Entry level quest for new players
quest_id: quest_cellar_rats
name: "Rat Problem in the Cellar"
description: |
Giant rats have infested the Rusty Anchor's cellar, threatening
the tavern's food supplies and scaring away customers. Grom the
bartender needs someone to clear them out.
difficulty: easy
# NPC Quest Givers (quest-centric design)
quest_giver_npc_ids:
- npc_grom_ironbeard
quest_giver_name: "Grom Ironbeard"
# Location context
location_id: crossville_tavern
region_id: crossville
# Offering conditions
offering_triggers:
location_types: ["tavern"]
specific_locations: ["crossville_tavern"]
min_character_level: 1
max_character_level: 5
required_quests_completed: []
probability_weights:
tavern: 0.35
town: 0.15
# NPC-specific offer dialogue
npc_offer_dialogues:
npc_grom_ironbeard:
dialogue: |
*leans in conspiratorially* Got a problem, friend. Giant rats
in me cellar. Been scaring off the paying customers and getting
into the ale stores. 50 gold for whoever clears 'em out.
conditions:
min_relationship: 30
required_flags: []
forbidden_flags: ["refused_rat_quest"]
# What NPCs know about this quest
npc_quest_knowledge:
npc_grom_ironbeard:
- "The rats started appearing about a week ago"
- "They seem bigger than normal rats - something's not right"
- "Old smuggling tunnels under the cellar might be where they're coming from"
- "Lost three kegs of dwarven stout to the vermin already"
# Embedded lore for AI context
lore_context:
backstory: |
The cellar of the Rusty Anchor connects to old smuggling tunnels
from Captain Morgath's days, sealed decades ago. Recent earthquakes
may have reopened them, allowing creatures to emerge.
world_connections:
- "The earthquakes that opened the tunnels also disturbed the Old Mines"
- "Giant rats are unnatural - possibly corrupted by dark magic from below"
regional_hints:
- "Smuggling was common 50 years ago in Crossville"
- "Captain Morgath was a notorious smuggler who disappeared mysteriously"
# Narrative hooks for natural dialogue
dialogue_templates:
narrative_hooks:
- "mentions unusual scratching sounds from below"
- "complains about spoiled food supplies"
- "nervously glances toward the cellar door"
- "asks if you've ever dealt with vermin before"
ambient_hints:
- "You notice scratch marks near the cellar door"
- "A faint squeaking echoes from somewhere below"
- "The air carries a musty, unpleasant odor"
# Objectives
objectives:
- objective_id: kill_rats
description: "Clear out the giant rats"
objective_type: kill
required_progress: 10
target_enemy_type: giant_rat
- objective_id: find_source
description: "Discover where the rats are coming from"
objective_type: discover
required_progress: 1
target_location_id: smuggler_tunnel_entrance
# Rewards
rewards:
gold: 50
experience: 100
items: []
relationship_bonuses:
npc_grom_ironbeard: 10
unlocks_quests: ["quest_tunnel_mystery"]
reveals_locations: ["crossville_tunnels"]
# Completion dialogue
completion_dialogue:
npc_grom_ironbeard: |
*actually smiles* Well done, friend! Rats are gone, cellar's safe.
Here's your coin, well earned. Drink's on the house tonight.
*pauses* You say you found old tunnels down there? Interesting...
tags:
- starter
- combat
- exploration

View File

@@ -0,0 +1,109 @@
# Missing Tools - Simple fetch quest for new players
quest_id: quest_missing_tools
name: "The Blacksmith's Missing Tools"
description: |
Hilda the blacksmith's best hammers and tongs have gone missing.
She suspects they were stolen by mischievous goblins who've been
spotted near the forest edge.
difficulty: easy
# NPC Quest Givers
quest_giver_npc_ids:
- npc_blacksmith_hilda
quest_giver_name: "Hilda Stoneforge"
# Location context
location_id: crossville_forge
region_id: crossville
# Offering conditions
offering_triggers:
location_types: ["town", "shop"]
specific_locations: ["crossville_forge", "crossville_village"]
min_character_level: 1
max_character_level: 4
required_quests_completed: []
probability_weights:
town: 0.25
shop: 0.30
# NPC-specific offer dialogue
npc_offer_dialogues:
npc_blacksmith_hilda:
dialogue: |
*sighs heavily* Some thieving little beasts made off with me
best tools last night. Saw goblin tracks leading toward the
forest. Can't do proper work without 'em. Help me out?
conditions:
min_relationship: 20
required_flags: []
forbidden_flags: []
# What NPCs know about this quest
npc_quest_knowledge:
npc_blacksmith_hilda:
- "The tools were passed down from her father"
- "Goblins have been more active lately near the forest"
- "She heard strange noises two nights ago but thought it was just animals"
- "The tools are distinctive - marked with the Stoneforge family crest"
# Embedded lore
lore_context:
backstory: |
The Stoneforge family has been the village's blacksmiths for
four generations. Hilda's tools are not just valuable - they're
family heirlooms with sentimental worth.
world_connections:
- "Goblins typically avoid settlements unless driven by something"
- "Recent disturbances in the region have made all creatures more bold"
regional_hints:
- "Goblin warrens exist in the rocky hills east of the forest"
- "The Stoneforge family once forged weapons for the village militia"
# Narrative hooks
dialogue_templates:
narrative_hooks:
- "mentions being unable to fill orders without proper tools"
- "looks frustrated at a half-finished project"
- "grumbles about creatures getting bolder"
- "offers a discount on future work as additional thanks"
ambient_hints:
- "You notice the forge is quieter than usual"
- "Several half-finished weapons sit abandoned on workbenches"
# Objectives
objectives:
- objective_id: track_goblins
description: "Follow the goblin tracks into the forest"
objective_type: travel
required_progress: 1
target_location_id: forest_goblin_camp
- objective_id: recover_tools
description: "Recover Hilda's stolen tools"
objective_type: collect
required_progress: 1
target_item_id: stoneforge_tools
# Rewards
rewards:
gold: 35
experience: 75
items: []
relationship_bonuses:
npc_blacksmith_hilda: 15
unlocks_quests: []
reveals_locations: ["forest_goblin_camp"]
# Completion dialogue
completion_dialogue:
npc_blacksmith_hilda: |
*eyes light up* My tools! You found them! By the ancestors,
I thought they were gone forever. Here's your coin, and know
that you've got a friend at the Stoneforge. Come by anytime
you need gear - I'll give you the family discount.
tags:
- starter
- fetch
- exploration

View File

@@ -0,0 +1,146 @@
# Dungeon Depths - Challenging dungeon crawl quest
quest_id: quest_dungeon_depths
name: "Secrets of the Old Mines"
description: |
The sealed entrance to Crossville's abandoned mines has been breached.
Strange sounds and foul odors emanate from within, and livestock have
begun to disappear. The mayor needs someone brave enough to investigate
what ancient evil has awakened in the depths.
difficulty: hard
# NPC Quest Givers
quest_giver_npc_ids:
- npc_mayor_aldric
quest_giver_name: "Mayor Aldric Thornwood"
# Location context
location_id: crossville_village
region_id: crossville
# Offering conditions
offering_triggers:
location_types: ["town", "official"]
specific_locations: ["crossville_village", "crossville_town_hall"]
min_character_level: 6
max_character_level: 15
required_quests_completed:
- quest_bandit_threat # Must have proven yourself
probability_weights:
town: 0.15
official: 0.25
# NPC-specific offer dialogue
npc_offer_dialogues:
npc_mayor_aldric:
dialogue: |
*closes the door and speaks in hushed tones* What I'm about
to tell you cannot leave this room. The Old Mines... they were
sealed for a reason. My grandfather saw to it fifty years ago.
Now something has broken through. I need someone I can trust
to go down there and find out what we're dealing with.
*pauses* And to ensure it stays buried.
conditions:
min_relationship: 60
required_flags: ["completed_bandit_quest"]
forbidden_flags: []
# What NPCs know
npc_quest_knowledge:
npc_mayor_aldric:
- "The mines were sealed after a cave-in killed twenty miners"
- "His grandfather's journal mentions 'that which must not wake'"
- "The recent earthquakes may have disturbed ancient seals"
- "He's being blackmailed by someone who knows the truth"
- "The Thornwood family has guarded this secret for generations"
# Embedded lore
lore_context:
backstory: |
Fifty years ago, miners in Crossville dug too deep and broke into
ancient catacombs predating human settlement. What they found
drove the survivors mad. The mines were sealed, the entrance
buried, and the truth hidden. The Thornwood family has kept
this secret ever since, fearing what might happen if the truth
emerged.
world_connections:
- "The catacombs may predate the Five Kingdoms themselves"
- "Similar sealed places exist across the realm - ancient evils best forgotten"
- "The earthquakes affecting the region all seem centered on these old sites"
regional_hints:
- "Old-timers speak of the 'quiet years' after the mine closed"
- "The Thornwood family rose to prominence around the same time"
- "Some say the mayor's nightmares are why he never drinks"
# Narrative hooks
dialogue_templates:
narrative_hooks:
- "speaks of family duty and burden"
- "reveals more than intended when stressed"
- "offers to share grandfather's journal"
- "warns of dangers beyond mere monsters"
ambient_hints:
- "The mayor's hands shake when the mines are mentioned"
- "Old maps on his wall show the mine entrance marked with warnings"
- "A locked chest in the corner bears the Thornwood seal"
# Objectives
objectives:
- objective_id: enter_mines
description: "Enter the Old Mines through the breached seal"
objective_type: travel
required_progress: 1
target_location_id: old_mines_entrance
- objective_id: explore_depths
description: "Explore the mine depths"
objective_type: discover
required_progress: 3
target_location_id: mine_depths
- objective_id: defeat_guardians
description: "Defeat the awakened guardians"
objective_type: kill
required_progress: 5
target_enemy_type: ancient_guardian
- objective_id: find_crypt
description: "Discover the ancient crypt"
objective_type: discover
required_progress: 1
target_location_id: ancient_crypt
- objective_id: seal_evil
description: "Reseal or destroy what lies within"
objective_type: interact
required_progress: 1
target_npc_id: ancient_seal
# Rewards
rewards:
gold: 500
experience: 750
items:
- ancient_medallion
- thornwood_journal
relationship_bonuses:
npc_mayor_aldric: 30
unlocks_quests: ["quest_ancient_truth"]
reveals_locations: ["old_mines", "ancient_crypt"]
# Completion dialogue
completion_dialogue:
npc_mayor_aldric: |
*sinks into his chair* It's done then? The seal holds?
*long pause* You've done what my family couldn't for fifty
years. You've seen what lies beneath our village. I trust
you understand why this must remain secret.
*slides a heavy chest across the desk*
Your reward. And my eternal gratitude. The Thornwood family
owes you a debt we can never fully repay. If you ever need
anything... anything at all... my door is always open to you.
tags:
- dungeon
- combat
- story
- secret

View File

@@ -0,0 +1,121 @@
# Bandit Threat - Combat-focused medium quest
quest_id: quest_bandit_threat
name: "The Bandit Threat"
description: |
A gang of bandits has been terrorizing travelers on the roads
near Crossville. Mayor Aldric is offering a substantial reward
for anyone who can deal with the threat permanently.
difficulty: medium
# NPC Quest Givers
quest_giver_npc_ids:
- npc_mayor_aldric
quest_giver_name: "Mayor Aldric Thornwood"
# Location context
location_id: crossville_village
region_id: crossville
# Offering conditions
offering_triggers:
location_types: ["town", "official"]
specific_locations: ["crossville_village", "crossville_town_hall"]
min_character_level: 3
max_character_level: 8
required_quests_completed: []
probability_weights:
town: 0.20
official: 0.40
# NPC-specific offer dialogue
npc_offer_dialogues:
npc_mayor_aldric:
dialogue: |
*straightens his chain of office* Crossville faces a grave threat.
Bandits have been attacking merchants on the eastern road. Trade
is suffering, and I fear for our people's safety. The village
will pay handsomely for someone who can... resolve this situation.
conditions:
min_relationship: 35
required_flags: []
forbidden_flags: ["failed_bandit_quest"]
# What NPCs know
npc_quest_knowledge:
npc_mayor_aldric:
- "The bandits have a camp somewhere in the eastern forest"
- "They're led by a man called 'Blackhand' - a former soldier"
- "At least a dozen bandits, well-armed and organized"
- "Three merchant caravans have been hit in the past month"
- "The militia is too small to mount an assault"
# Embedded lore
lore_context:
backstory: |
The bandit leader Blackhand was once a soldier in the King's
army. After the war ended, he turned to brigandry rather than
return to a life of farming. His military training makes his
gang far more dangerous than common thieves.
world_connections:
- "Many veterans turned to banditry after the war - a kingdom-wide problem"
- "The mayor may know more about Blackhand than he lets on"
regional_hints:
- "The eastern forest road is the main trade route to the capital"
- "Without trade, Crossville's economy will suffer greatly"
# Narrative hooks
dialogue_templates:
narrative_hooks:
- "speaks of the economic impact on the village"
- "mentions concerned merchants refusing to travel"
- "hints at political pressure from higher authorities"
- "shows genuine worry for the common folk"
ambient_hints:
- "You notice fewer merchants in the market than expected"
- "Guards at the village gate look nervous and understaffed"
- "Wanted posters for 'Blackhand' are posted on walls"
# Objectives
objectives:
- objective_id: find_camp
description: "Locate the bandit camp in the eastern forest"
objective_type: discover
required_progress: 1
target_location_id: bandit_camp
- objective_id: defeat_bandits
description: "Defeat the bandits"
objective_type: kill
required_progress: 8
target_enemy_type: bandit
- objective_id: defeat_blackhand
description: "Defeat or capture Blackhand"
objective_type: kill
required_progress: 1
target_enemy_type: blackhand_leader
# Rewards
rewards:
gold: 200
experience: 350
items:
- blackhand_sword
relationship_bonuses:
npc_mayor_aldric: 20
unlocks_quests: ["quest_mayors_secret"]
reveals_locations: ["bandit_camp"]
# Completion dialogue
completion_dialogue:
npc_mayor_aldric: |
*visible relief* The bandits are dealt with? This is excellent
news. You've done Crossville a great service. *hands over a
heavy coin purse* Your reward, as promised. And... I may have
other matters that require someone of your capabilities. We
should speak again soon.
tags:
- combat
- exploration
- story

View File

@@ -0,0 +1,143 @@
# Missing Shipment - Investigation and exploration quest
quest_id: quest_missing_shipment
name: "The Missing Shipment"
description: |
A shipment of dwarven goods, including rare ale and tools, never
arrived from the mountain passes. The merchant who was transporting
it has not been seen in days. Something must have happened on the road.
difficulty: medium
# NPC Quest Givers
quest_giver_npc_ids:
- npc_grom_ironbeard
- npc_blacksmith_hilda
quest_giver_name: "Grom Ironbeard"
# Location context
location_id: crossville_tavern
region_id: crossville
# Offering conditions
offering_triggers:
location_types: ["tavern", "shop"]
specific_locations: ["crossville_tavern", "crossville_forge"]
min_character_level: 4
max_character_level: 10
required_quests_completed:
- quest_cellar_rats # Must have helped Grom before
probability_weights:
tavern: 0.30
shop: 0.25
# NPC-specific offer dialogue
npc_offer_dialogues:
npc_grom_ironbeard:
dialogue: |
*polishes glass nervously* My cousin Durgin was supposed to
arrive three days ago with a shipment from the mountain holds.
Dwarven stout, quality ore, fine tools. He's never late.
*voice drops* Something's happened on the mountain road.
I'm too old to go myself, but I'll pay well for news of him.
conditions:
min_relationship: 50
required_flags: ["completed_cellar_rats"]
forbidden_flags: []
npc_blacksmith_hilda:
dialogue: |
*frowns at empty shelves* My shipment of dwarven ore never
arrived. Durgin Stonefoot was bringing it - he's Grom's cousin.
Without that ore, I can't forge anything worth selling.
The mountain road can be dangerous... would you look into it?
conditions:
min_relationship: 40
required_flags: []
forbidden_flags: []
# What NPCs know
npc_quest_knowledge:
npc_grom_ironbeard:
- "Durgin always takes the north pass - it's longer but safer"
- "He travels with two guards and a pack mule"
- "The shipment is worth at least 500 gold"
- "There have been rumors of strange creatures in the mountains lately"
npc_blacksmith_hilda:
- "Durgin is reliable - never missed a delivery in 15 years"
- "The ore he brings is from the deep mines - best quality"
- "Grom is worried sick about his cousin"
# Embedded lore
lore_context:
backstory: |
Durgin Stonefoot has been making the mountain crossing for years,
bringing goods from the dwarven holds to the human settlements
below. He's a seasoned traveler who knows every rock and crevice
of the mountain road.
world_connections:
- "The mountain passes have seen increased monster activity"
- "Trade routes between dwarves and humans are vital to both economies"
regional_hints:
- "The north pass winds through old ruins from the First Age"
- "Travelers speak of hearing strange howls at night"
# Narrative hooks
dialogue_templates:
narrative_hooks:
- "mentions family connection to the missing merchant"
- "worries about more than just the lost goods"
- "offers personal items as a bonus reward"
- "knows something about the mountain dangers"
ambient_hints:
- "Empty barrels line the cellar where shipments should be"
- "Other merchants discuss avoiding the mountain road"
# Objectives
objectives:
- objective_id: search_road
description: "Search the mountain road for signs of the shipment"
objective_type: travel
required_progress: 1
target_location_id: mountain_pass
- objective_id: find_durgin
description: "Discover what happened to Durgin"
objective_type: discover
required_progress: 1
target_location_id: durgin_campsite
- objective_id: recover_goods
description: "Recover the missing goods (if possible)"
objective_type: collect
required_progress: 1
target_item_id: dwarven_shipment
# Rewards
rewards:
gold: 150
experience: 300
items:
- dwarven_ale_keg
relationship_bonuses:
npc_grom_ironbeard: 15
npc_blacksmith_hilda: 10
unlocks_quests: ["quest_mountain_menace"]
reveals_locations: ["mountain_pass", "durgin_campsite"]
# Completion dialogue
completion_dialogue:
npc_grom_ironbeard: |
*relief floods his face* Durgin is alive? Thank the ancestors!
*takes the recovered goods* And you brought back what you could.
You've done more than I could ask. Here's your payment, and...
*pulls out a small flask* ...my personal reserve. Drink it in
good health, friend.
npc_blacksmith_hilda: |
*examines the recovered ore* This will do nicely. Thank you
for finding out what happened. Poor Durgin - I hope he recovers.
You've earned both coin and my gratitude.
tags:
- investigation
- exploration
- story

View File

@@ -0,0 +1,141 @@
shop_id: "general_store"
shop_name: "General Store"
shop_description: "A well-stocked general store with essential supplies for adventurers."
shopkeeper_name: "Merchant Guildmaster"
sell_rate: 0.5 # 50% of buy price when selling items back
inventory:
# === CONSUMABLES ===
# Health Potions
- item_id: "health_potion_tiny"
stock: -1
price: 10
- item_id: "health_potion_small"
stock: -1
price: 25
- item_id: "health_potion_medium"
stock: -1
price: 75
# Mana Potions
- item_id: "mana_potion_tiny"
stock: -1
price: 15
- item_id: "mana_potion_small"
stock: -1
price: 35
- item_id: "mana_potion_medium"
stock: -1
price: 100
# Hybrid Potions
- item_id: "rejuvenation_potion_small"
stock: -1
price: 50
# Cures
- item_id: "antidote"
stock: -1
price: 20
- item_id: "smelling_salts"
stock: -1
price: 15
# Throwables
- item_id: "smoke_bomb"
stock: -1
price: 30
- item_id: "oil_flask"
stock: -1
price: 25
# Food
- item_id: "bread_loaf"
stock: -1
price: 5
- item_id: "ration"
stock: -1
price: 10
- item_id: "cooked_meat"
stock: -1
price: 15
# === WEAPONS (base templates, sold as common) ===
- item_id: "dagger"
stock: -1
price: 25
- item_id: "short_sword"
stock: -1
price: 50
- item_id: "club"
stock: -1
price: 15
- item_id: "shortbow"
stock: -1
price: 45
- item_id: "wand"
stock: -1
price: 40
- item_id: "quarterstaff"
stock: -1
price: 20
# === ARMOR ===
- item_id: "cloth_robe"
stock: -1
price: 30
- item_id: "leather_vest"
stock: -1
price: 60
- item_id: "chain_shirt"
stock: -1
price: 120
- item_id: "scale_mail"
stock: -1
price: 200
# === SHIELDS ===
- item_id: "buckler"
stock: -1
price: 25
- item_id: "wooden_shield"
stock: -1
price: 40
- item_id: "tower_shield"
stock: -1
price: 150
# === ACCESSORIES ===
- item_id: "copper_ring"
stock: -1
price: 20
- item_id: "silver_ring"
stock: -1
price: 50
- item_id: "wooden_pendant"
stock: -1
price: 25
- item_id: "leather_belt"
stock: -1
price: 15

View File

@@ -0,0 +1,487 @@
# Accessory items available for purchase from NPC shops
# These are fixed-stat items (not procedurally generated)
#
# Accessory Slots:
# - ring: Finger slot (typically 2 slots available)
# - amulet: Neck slot (1 slot)
# - belt: Waist slot (1 slot)
#
# All accessories use item_type: armor with their respective slot
items:
# ==========================================================================
# RINGS - Basic
# ==========================================================================
copper_ring:
name: "Copper Ring"
item_type: armor
slot: ring
rarity: common
description: "A simple copper band. Provides a minor boost to the wearer's capabilities."
value: 25
required_level: 1
is_tradeable: true
stat_bonuses:
luck: 1
silver_ring:
name: "Silver Ring"
item_type: armor
slot: ring
rarity: common
description: "A polished silver ring. A step up from copper, with better enchantment potential."
value: 50
required_level: 2
is_tradeable: true
stat_bonuses:
luck: 2
gold_ring:
name: "Gold Ring"
item_type: armor
slot: ring
rarity: uncommon
description: "A gleaming gold ring. The metal's purity enhances its magical properties."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
luck: 3
# ==========================================================================
# RINGS - Stat-Specific
# ==========================================================================
ring_of_strength:
name: "Ring of Strength"
item_type: armor
slot: ring
rarity: uncommon
description: "A heavy iron ring etched with symbols of power. Increases physical might."
value: 120
required_level: 3
is_tradeable: true
stat_bonuses:
strength: 3
ring_of_agility:
name: "Ring of Agility"
item_type: armor
slot: ring
rarity: uncommon
description: "A lightweight ring of woven silver. Enhances speed and reflexes."
value: 120
required_level: 3
is_tradeable: true
stat_bonuses:
dexterity: 3
ring_of_intellect:
name: "Ring of Intellect"
item_type: armor
slot: ring
rarity: uncommon
description: "A sapphire-studded ring that sharpens the mind."
value: 120
required_level: 3
is_tradeable: true
stat_bonuses:
intelligence: 3
ring_of_wisdom:
name: "Ring of Wisdom"
item_type: armor
slot: ring
rarity: uncommon
description: "An ancient ring carved from petrified wood. Grants insight and perception."
value: 120
required_level: 3
is_tradeable: true
stat_bonuses:
wisdom: 3
ring_of_fortitude:
name: "Ring of Fortitude"
item_type: armor
slot: ring
rarity: uncommon
description: "A thick band of blackened steel. Toughens the body against harm."
value: 120
required_level: 3
is_tradeable: true
stat_bonuses:
constitution: 3
ring_of_charisma:
name: "Ring of Charisma"
item_type: armor
slot: ring
rarity: uncommon
description: "A elegant ring with a hypnotic gem. Enhances presence and charm."
value: 120
required_level: 3
is_tradeable: true
stat_bonuses:
charisma: 3
# ==========================================================================
# RINGS - Combat
# ==========================================================================
ring_of_protection:
name: "Ring of Protection"
item_type: armor
slot: ring
rarity: rare
description: "A ring inscribed with protective runes. Creates a subtle barrier around the wearer."
value: 200
required_level: 4
is_tradeable: true
base_defense: 5
ring_of_the_magi:
name: "Ring of the Magi"
item_type: armor
slot: ring
rarity: rare
description: "A ring pulsing with arcane energy. Amplifies spellcasting power."
value: 200
required_level: 4
is_tradeable: true
base_spell_power: 5
ring_of_evasion:
name: "Ring of Evasion"
item_type: armor
slot: ring
rarity: rare
description: "A shadowy ring that seems to flicker in and out of sight."
value: 175
required_level: 4
is_tradeable: true
dodge_bonus: 0.05
ring_of_striking:
name: "Ring of Striking"
item_type: armor
slot: ring
rarity: rare
description: "A ring with a ruby set in iron. Enhances the force of physical attacks."
value: 185
required_level: 4
is_tradeable: true
damage_bonus: 3
# ==========================================================================
# AMULETS - Basic
# ==========================================================================
wooden_pendant:
name: "Wooden Pendant"
item_type: armor
slot: amulet
rarity: common
description: "A carved wooden pendant on a leather cord. A humble but effective charm."
value: 30
required_level: 1
is_tradeable: true
max_hp_bonus: 5
bone_amulet:
name: "Bone Amulet"
item_type: armor
slot: amulet
rarity: common
description: "An amulet carved from the bone of a magical beast. Provides minor magical resistance."
value: 40
required_level: 1
is_tradeable: true
base_resistance: 1
silver_locket:
name: "Silver Locket"
item_type: armor
slot: amulet
rarity: uncommon
description: "A small silver locket that can hold a keepsake. Strengthens the wearer's resolve."
value: 75
required_level: 2
is_tradeable: true
max_hp_bonus: 10
travelers_charm:
name: "Traveler's Charm"
item_type: armor
slot: amulet
rarity: common
description: "A lucky charm carried by wanderers. Said to bring fortune on the road."
value: 50
required_level: 2
is_tradeable: true
stat_bonuses:
strength: 1
dexterity: 1
constitution: 1
intelligence: 1
wisdom: 1
charisma: 1
# ==========================================================================
# AMULETS - Specialized
# ==========================================================================
amulet_of_health:
name: "Amulet of Health"
item_type: armor
slot: amulet
rarity: uncommon
description: "A ruby-studded amulet that pulses with life energy."
value: 125
required_level: 3
is_tradeable: true
max_hp_bonus: 15
amulet_of_mana:
name: "Amulet of Mana"
item_type: armor
slot: amulet
rarity: uncommon
description: "A sapphire pendant that glows with arcane power."
value: 125
required_level: 3
is_tradeable: true
max_mp_bonus: 15
amulet_of_warding:
name: "Amulet of Warding"
item_type: armor
slot: amulet
rarity: rare
description: "An intricate amulet covered in protective sigils. Guards against magical harm."
value: 175
required_level: 4
is_tradeable: true
base_resistance: 3
amulet_of_regeneration:
name: "Amulet of Regeneration"
item_type: armor
slot: amulet
rarity: rare
description: "A green gem amulet infused with troll essence. Slowly heals wounds over time."
value: 200
required_level: 5
is_tradeable: true
hp_regen: 2
amulet_of_focus:
name: "Amulet of Focus"
item_type: armor
slot: amulet
rarity: rare
description: "A crystal pendant that helps the wearer concentrate on spellcasting."
value: 175
required_level: 4
is_tradeable: true
base_spell_power: 4
amulet_of_vitality:
name: "Amulet of Vitality"
item_type: armor
slot: amulet
rarity: rare
description: "A vibrant amber amulet that radiates warmth. Enhances the wearer's constitution."
value: 180
required_level: 4
is_tradeable: true
stat_bonuses:
constitution: 4
max_hp_bonus: 10
warriors_medallion:
name: "Warrior's Medallion"
item_type: armor
slot: amulet
rarity: uncommon
description: "A bronze medallion awarded to proven warriors. Inspires courage in battle."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
strength: 2
constitution: 2
sorcerers_pendant:
name: "Sorcerer's Pendant"
item_type: armor
slot: amulet
rarity: uncommon
description: "A mystical pendant favored by apprentice mages."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
intelligence: 2
wisdom: 2
# ==========================================================================
# BELTS - Basic
# ==========================================================================
leather_belt:
name: "Leather Belt"
item_type: armor
slot: belt
rarity: common
description: "A sturdy leather belt with pouches for carrying supplies."
value: 15
required_level: 1
is_tradeable: true
carry_capacity_bonus: 5
adventurers_belt:
name: "Adventurer's Belt"
item_type: armor
slot: belt
rarity: common
description: "A well-worn belt with loops and pouches. Standard gear for dungeon delvers."
value: 35
required_level: 1
is_tradeable: true
potion_slots_bonus: 2
reinforced_belt:
name: "Reinforced Belt"
item_type: armor
slot: belt
rarity: common
description: "A thick belt reinforced with metal studs. Provides minor protection."
value: 45
required_level: 2
is_tradeable: true
base_defense: 1
# ==========================================================================
# BELTS - Class-Specific
# ==========================================================================
warriors_girdle:
name: "Warrior's Girdle"
item_type: armor
slot: belt
rarity: uncommon
description: "A wide leather girdle worn by seasoned fighters. Supports heavy armor and enhances martial prowess."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
strength: 2
constitution: 2
rogues_sash:
name: "Rogue's Sash"
item_type: armor
slot: belt
rarity: uncommon
description: "A dark silk sash with hidden pockets. Favored by thieves and assassins."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
dexterity: 2
crit_chance_bonus: 0.05
mages_cord:
name: "Mage's Cord"
item_type: armor
slot: belt
rarity: uncommon
description: "A woven cord inscribed with arcane symbols. Channels magical energy more efficiently."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
intelligence: 2
base_spell_power: 5
clerics_cincture:
name: "Cleric's Cincture"
item_type: armor
slot: belt
rarity: uncommon
description: "A blessed rope belt worn by the faithful. Enhances divine magic."
value: 100
required_level: 3
is_tradeable: true
stat_bonuses:
wisdom: 2
base_resistance: 2
# ==========================================================================
# BELTS - Combat
# ==========================================================================
belt_of_giant_strength:
name: "Belt of Giant Strength"
item_type: armor
slot: belt
rarity: rare
description: "A massive belt made from giant's hide. Grants tremendous physical power."
value: 200
required_level: 5
is_tradeable: true
stat_bonuses:
strength: 5
belt_of_endurance:
name: "Belt of Endurance"
item_type: armor
slot: belt
rarity: rare
description: "A sturdy belt that seems to lighten burdens and boost stamina."
value: 175
required_level: 4
is_tradeable: true
stat_bonuses:
constitution: 2
max_hp_bonus: 20
belt_of_the_serpent:
name: "Belt of the Serpent"
item_type: armor
slot: belt
rarity: rare
description: "A belt made from serpent scales. Grants uncanny flexibility and reflexes."
value: 185
required_level: 4
is_tradeable: true
stat_bonuses:
dexterity: 4
dodge_bonus: 0.03
war_belt:
name: "War Belt"
item_type: armor
slot: belt
rarity: rare
description: "A heavy battle belt with weapon holsters. Designed for extended combat."
value: 160
required_level: 4
is_tradeable: true
base_defense: 3
stat_bonuses:
strength: 2
constitution: 1
utility_belt:
name: "Utility Belt"
item_type: armor
slot: belt
rarity: uncommon
description: "A belt with numerous pouches and clips. Perfect for carrying tools and supplies."
value: 75
required_level: 2
is_tradeable: true
carry_capacity_bonus: 10
potion_slots_bonus: 1

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,8 @@ class Character:
player_class: Character's class (determines base stats and skill trees) player_class: Character's class (determines base stats and skill trees)
origin: Character's backstory origin (saved for AI DM narrative hooks) origin: Character's backstory origin (saved for AI DM narrative hooks)
level: Current level level: Current level
experience: Current XP points experience: Current XP progress toward next level (resets on level-up)
total_xp: Cumulative XP earned across all levels (never decreases)
base_stats: Base stats (from class + level-ups) base_stats: Base stats (from class + level-ups)
unlocked_skills: List of skill_ids that have been unlocked unlocked_skills: List of skill_ids that have been unlocked
inventory: All items the character owns inventory: All items the character owns
@@ -53,7 +54,8 @@ class Character:
player_class: PlayerClass player_class: PlayerClass
origin: Origin origin: Origin
level: int = 1 level: int = 1
experience: int = 0 experience: int = 0 # Current level progress (resets on level-up)
total_xp: int = 0 # Cumulative XP (never decreases)
# Stats and progression # Stats and progression
base_stats: Stats = field(default_factory=Stats) base_stats: Stats = field(default_factory=Stats)
@@ -66,6 +68,8 @@ class Character:
# Quests and exploration # Quests and exploration
active_quests: List[str] = field(default_factory=list) active_quests: List[str] = field(default_factory=list)
completed_quests: List[str] = field(default_factory=list)
quest_states: Dict[str, Dict] = field(default_factory=dict) # quest_id -> CharacterQuestState.to_dict()
discovered_locations: List[str] = field(default_factory=list) discovered_locations: List[str] = field(default_factory=list)
current_location: Optional[str] = None # Set to origin starting location on creation current_location: Optional[str] = None # Set to origin starting location on creation
@@ -313,6 +317,9 @@ class Character:
""" """
Add experience points and check for level up. Add experience points and check for level up.
Updates both current level progress (experience) and cumulative total (total_xp).
The cumulative total never decreases, providing players a sense of overall progression.
Args: Args:
xp: Amount of experience to add xp: Amount of experience to add
@@ -320,6 +327,7 @@ class Character:
True if character leveled up, False otherwise True if character leveled up, False otherwise
""" """
self.experience += xp self.experience += xp
self.total_xp += xp # Track cumulative XP (never decreases)
required_xp = self._calculate_xp_for_next_level() required_xp = self._calculate_xp_for_next_level()
if self.experience >= required_xp: if self.experience >= required_xp:
@@ -332,15 +340,18 @@ class Character:
""" """
Level up the character. Level up the character.
- Increases level - Increases level by 1
- Resets experience to overflow amount - Resets experience to overflow amount (XP beyond requirement carries over)
- Preserves total_xp (cumulative XP is never modified here)
- Grants 1 skill point (level - unlocked_skills count)
- Could grant stat increases (future enhancement) - Could grant stat increases (future enhancement)
""" """
required_xp = self._calculate_xp_for_next_level() required_xp = self._calculate_xp_for_next_level()
overflow_xp = self.experience - required_xp overflow_xp = self.experience - required_xp
self.level += 1 self.level += 1
self.experience = overflow_xp self.experience = overflow_xp # Reset current level progress
# total_xp remains unchanged - it's cumulative and never decreases
# Future: Apply stat increases based on class # Future: Apply stat increases based on class
# For now, stats are increased manually via skill points # For now, stats are increased manually via skill points
@@ -357,6 +368,19 @@ class Character:
""" """
return int(100 * (self.level ** 1.5)) return int(100 * (self.level ** 1.5))
@property
def xp_to_next_level(self) -> int:
"""
Get XP remaining until next level.
This is a computed property for UI display showing progress bars
and "X/Y XP to next level" displays.
Returns:
Amount of XP needed to reach next level
"""
return self._calculate_xp_for_next_level() - self.experience
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
""" """
Serialize character to dictionary for JSON storage. Serialize character to dictionary for JSON storage.
@@ -372,12 +396,17 @@ class Character:
"origin": self.origin.to_dict(), "origin": self.origin.to_dict(),
"level": self.level, "level": self.level,
"experience": self.experience, "experience": self.experience,
"total_xp": self.total_xp,
"xp_to_next_level": self.xp_to_next_level,
"xp_required_for_next_level": self._calculate_xp_for_next_level(),
"base_stats": self.base_stats.to_dict(), "base_stats": self.base_stats.to_dict(),
"unlocked_skills": self.unlocked_skills, "unlocked_skills": self.unlocked_skills,
"inventory": [item.to_dict() for item in self.inventory], "inventory": [item.to_dict() for item in self.inventory],
"equipped": {slot: item.to_dict() for slot, item in self.equipped.items()}, "equipped": {slot: item.to_dict() for slot, item in self.equipped.items()},
"gold": self.gold, "gold": self.gold,
"active_quests": self.active_quests, "active_quests": self.active_quests,
"completed_quests": self.completed_quests,
"quest_states": self.quest_states,
"discovered_locations": self.discovered_locations, "discovered_locations": self.discovered_locations,
"current_location": self.current_location, "current_location": self.current_location,
"npc_interactions": self.npc_interactions, "npc_interactions": self.npc_interactions,
@@ -461,12 +490,15 @@ class Character:
origin=origin, origin=origin,
level=data.get("level", 1), level=data.get("level", 1),
experience=data.get("experience", 0), experience=data.get("experience", 0),
total_xp=data.get("total_xp", 0), # Default 0 for legacy data
base_stats=base_stats, base_stats=base_stats,
unlocked_skills=data.get("unlocked_skills", []), unlocked_skills=data.get("unlocked_skills", []),
inventory=inventory, inventory=inventory,
equipped=equipped, equipped=equipped,
gold=data.get("gold", 0), gold=data.get("gold", 0),
active_quests=data.get("active_quests", []), active_quests=data.get("active_quests", []),
completed_quests=data.get("completed_quests", []),
quest_states=data.get("quest_states", {}),
discovered_locations=data.get("discovered_locations", []), discovered_locations=data.get("discovered_locations", []),
current_location=data.get("current_location"), current_location=data.get("current_location"),
npc_interactions=data.get("npc_interactions", {}), npc_interactions=data.get("npc_interactions", {}),

649
api/app/models/quest.py Normal file
View File

@@ -0,0 +1,649 @@
"""
Quest data models for the quest system.
This module defines Quest and related dataclasses for the YAML-driven quest
system. Quests are loaded from YAML files and define their own NPC givers,
objectives, rewards, and offering conditions.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional
from enum import Enum
class ObjectiveType(Enum):
"""Types of quest objectives."""
KILL = "kill"
COLLECT = "collect"
TRAVEL = "travel"
INTERACT = "interact"
DISCOVER = "discover"
class QuestDifficulty(Enum):
"""Quest difficulty levels."""
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
EPIC = "epic"
class QuestStatus(Enum):
"""Status of a quest for a character."""
AVAILABLE = "available"
ACTIVE = "active"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class QuestObjective:
"""
A single objective within a quest.
Objectives track player progress toward quest completion. Each objective
has a type (kill, collect, etc.) and a required progress amount.
Attributes:
objective_id: Unique identifier within the quest
description: Player-facing description (e.g., "Kill 10 giant rats")
objective_type: Type of objective (kill, collect, travel, etc.)
required_progress: Target amount to complete (e.g., 10 for 10 kills)
current_progress: Current progress (tracked on character's quest state)
target_enemy_type: For kill objectives - enemy type to kill
target_item_id: For collect objectives - item to collect
target_location_id: For travel/discover objectives - location to reach
target_npc_id: For interact objectives - NPC to interact with
"""
objective_id: str
description: str
objective_type: ObjectiveType
required_progress: int = 1
current_progress: int = 0
target_enemy_type: Optional[str] = None
target_item_id: Optional[str] = None
target_location_id: Optional[str] = None
target_npc_id: Optional[str] = None
@property
def is_complete(self) -> bool:
"""Check if this objective is complete."""
return self.current_progress >= self.required_progress
@property
def progress_text(self) -> str:
"""Get formatted progress text (e.g., '5/10')."""
return f"{self.current_progress}/{self.required_progress}"
def to_dict(self) -> Dict[str, Any]:
"""Serialize objective to dictionary."""
return {
"objective_id": self.objective_id,
"description": self.description,
"objective_type": self.objective_type.value,
"required_progress": self.required_progress,
"current_progress": self.current_progress,
"target_enemy_type": self.target_enemy_type,
"target_item_id": self.target_item_id,
"target_location_id": self.target_location_id,
"target_npc_id": self.target_npc_id,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective':
"""Deserialize objective from dictionary."""
return cls(
objective_id=data["objective_id"],
description=data["description"],
objective_type=ObjectiveType(data["objective_type"]),
required_progress=data.get("required_progress", 1),
current_progress=data.get("current_progress", 0),
target_enemy_type=data.get("target_enemy_type"),
target_item_id=data.get("target_item_id"),
target_location_id=data.get("target_location_id"),
target_npc_id=data.get("target_npc_id"),
)
@dataclass
class QuestReward:
"""
Rewards granted upon quest completion.
Attributes:
gold: Gold amount
experience: XP amount
items: List of item IDs to grant
relationship_bonuses: NPC relationship increases {npc_id: amount}
unlocks_quests: Quest IDs that become available after completion
reveals_locations: Location IDs revealed upon completion
"""
gold: int = 0
experience: int = 0
items: List[str] = field(default_factory=list)
relationship_bonuses: Dict[str, int] = field(default_factory=dict)
unlocks_quests: List[str] = field(default_factory=list)
reveals_locations: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize rewards to dictionary."""
return {
"gold": self.gold,
"experience": self.experience,
"items": self.items,
"relationship_bonuses": self.relationship_bonuses,
"unlocks_quests": self.unlocks_quests,
"reveals_locations": self.reveals_locations,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward':
"""Deserialize rewards from dictionary."""
return cls(
gold=data.get("gold", 0),
experience=data.get("experience", 0),
items=data.get("items", []),
relationship_bonuses=data.get("relationship_bonuses", {}),
unlocks_quests=data.get("unlocks_quests", []),
reveals_locations=data.get("reveals_locations", []),
)
@dataclass
class QuestOfferConditions:
"""
Conditions required for an NPC to offer this quest.
These are checked per-NPC to determine if they can offer the quest
to a specific character based on relationship and flags.
Attributes:
min_relationship: Minimum relationship level required (0-100)
required_flags: Custom flags that must be set on character's NPC interaction
forbidden_flags: Custom flags that must NOT be set (e.g., "refused_this_quest")
"""
min_relationship: int = 0
required_flags: List[str] = field(default_factory=list)
forbidden_flags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize conditions to dictionary."""
return {
"min_relationship": self.min_relationship,
"required_flags": self.required_flags,
"forbidden_flags": self.forbidden_flags,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferConditions':
"""Deserialize conditions from dictionary."""
return cls(
min_relationship=data.get("min_relationship", 0),
required_flags=data.get("required_flags", []),
forbidden_flags=data.get("forbidden_flags", []),
)
@dataclass
class NPCOfferDialogue:
"""
NPC-specific dialogue for offering a quest.
Each NPC can have custom dialogue and conditions for offering a quest,
allowing different NPCs to present the same quest differently.
Attributes:
dialogue: The custom offer dialogue for this NPC
conditions: Conditions for this NPC to offer the quest
"""
dialogue: str
conditions: QuestOfferConditions = field(default_factory=QuestOfferConditions)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"dialogue": self.dialogue,
"conditions": self.conditions.to_dict(),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCOfferDialogue':
"""Deserialize from dictionary."""
conditions = QuestOfferConditions()
if data.get("conditions"):
conditions = QuestOfferConditions.from_dict(data["conditions"])
return cls(
dialogue=data.get("dialogue", ""),
conditions=conditions,
)
@dataclass
class QuestOfferingTriggers:
"""
Conditions for when a quest can be offered.
Controls the probability and eligibility for quest offering based on
location, character level, and prerequisite quests.
Attributes:
location_types: Location types where quest can be offered (e.g., ["tavern", "town"])
specific_locations: Specific location IDs (if more restrictive than types)
min_character_level: Minimum character level to receive quest
max_character_level: Maximum character level (optional upper bound)
required_quests_completed: Quest IDs that must be completed first
probability_weights: Probability by location type (e.g., {"tavern": 0.35})
"""
location_types: List[str] = field(default_factory=list)
specific_locations: List[str] = field(default_factory=list)
min_character_level: int = 1
max_character_level: int = 100
required_quests_completed: List[str] = field(default_factory=list)
probability_weights: Dict[str, float] = field(default_factory=dict)
def get_probability(self, location_type: str) -> float:
"""
Get the offering probability for a location type.
Args:
location_type: Type of location (e.g., "tavern", "town", "wilderness")
Returns:
Probability between 0.0 and 1.0
"""
return self.probability_weights.get(location_type, 0.0)
def to_dict(self) -> Dict[str, Any]:
"""Serialize triggers to dictionary."""
return {
"location_types": self.location_types,
"specific_locations": self.specific_locations,
"min_character_level": self.min_character_level,
"max_character_level": self.max_character_level,
"required_quests_completed": self.required_quests_completed,
"probability_weights": self.probability_weights,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferingTriggers':
"""Deserialize triggers from dictionary."""
return cls(
location_types=data.get("location_types", []),
specific_locations=data.get("specific_locations", []),
min_character_level=data.get("min_character_level", 1),
max_character_level=data.get("max_character_level", 100),
required_quests_completed=data.get("required_quests_completed", []),
probability_weights=data.get("probability_weights", {}),
)
@dataclass
class QuestDialogueTemplates:
"""
Dialogue templates for natural quest offering.
Provides narrative hooks and hints that the AI can use to naturally
weave quest offers into conversation.
Attributes:
narrative_hooks: Phrases the NPC might mention (e.g., "mentions scratching sounds")
ambient_hints: Environmental descriptions (e.g., "You notice scratch marks")
"""
narrative_hooks: List[str] = field(default_factory=list)
ambient_hints: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"narrative_hooks": self.narrative_hooks,
"ambient_hints": self.ambient_hints,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestDialogueTemplates':
"""Deserialize from dictionary."""
return cls(
narrative_hooks=data.get("narrative_hooks", []),
ambient_hints=data.get("ambient_hints", []),
)
@dataclass
class QuestLoreContext:
"""
Embedded lore context for quest offering.
Provides backstory and world context that the AI can reference when
discussing the quest with players. This serves as a stub until the
Weaviate vector database is implemented in Phase 6.
Attributes:
backstory: Background story for this quest
world_connections: How this quest connects to world events
regional_hints: Local/regional information related to the quest
"""
backstory: str = ""
world_connections: List[str] = field(default_factory=list)
regional_hints: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"backstory": self.backstory,
"world_connections": self.world_connections,
"regional_hints": self.regional_hints,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestLoreContext':
"""Deserialize from dictionary."""
return cls(
backstory=data.get("backstory", ""),
world_connections=data.get("world_connections", []),
regional_hints=data.get("regional_hints", []),
)
@dataclass
class Quest:
"""
Complete quest definition loaded from YAML.
Quests are the central data structure for the quest system. They define
their own NPC givers (quest-centric design), objectives, rewards, and
offering conditions.
Attributes:
quest_id: Unique identifier (e.g., "quest_cellar_rats")
name: Display name (e.g., "Rat Problem in the Cellar")
description: Full quest description
difficulty: Quest difficulty level
quest_giver_npc_ids: NPCs who can offer this quest
quest_giver_name: Display name fallback for quest giver
location_id: Primary location for this quest
region_id: Region this quest belongs to
objectives: List of objectives to complete
rewards: Rewards for completion
offering_triggers: When/where quest can be offered
npc_offer_dialogues: NPC-specific offer dialogue {npc_id: NPCOfferDialogue}
npc_quest_knowledge: What NPCs know about this quest {npc_id: [facts]}
lore_context: Embedded lore for AI context
dialogue_templates: Narrative hooks for natural offering
completion_dialogue: NPC dialogue upon completion {npc_id: dialogue}
tags: Metadata tags for filtering
"""
quest_id: str
name: str
description: str
difficulty: QuestDifficulty
quest_giver_npc_ids: List[str]
quest_giver_name: str = ""
location_id: str = ""
region_id: str = ""
objectives: List[QuestObjective] = field(default_factory=list)
rewards: QuestReward = field(default_factory=QuestReward)
offering_triggers: QuestOfferingTriggers = field(default_factory=QuestOfferingTriggers)
npc_offer_dialogues: Dict[str, NPCOfferDialogue] = field(default_factory=dict)
npc_quest_knowledge: Dict[str, List[str]] = field(default_factory=dict)
lore_context: QuestLoreContext = field(default_factory=QuestLoreContext)
dialogue_templates: QuestDialogueTemplates = field(default_factory=QuestDialogueTemplates)
completion_dialogue: Dict[str, str] = field(default_factory=dict)
tags: List[str] = field(default_factory=list)
@property
def is_complete(self) -> bool:
"""Check if all objectives are complete."""
return all(obj.is_complete for obj in self.objectives)
def get_offer_dialogue(self, npc_id: str) -> str:
"""
Get the offer dialogue for a specific NPC.
Args:
npc_id: The NPC's identifier
Returns:
Custom dialogue if defined, otherwise empty string
"""
if npc_id in self.npc_offer_dialogues:
return self.npc_offer_dialogues[npc_id].dialogue
return ""
def get_offer_conditions(self, npc_id: str) -> QuestOfferConditions:
"""
Get the offer conditions for a specific NPC.
Args:
npc_id: The NPC's identifier
Returns:
Conditions if defined, otherwise default conditions
"""
if npc_id in self.npc_offer_dialogues:
return self.npc_offer_dialogues[npc_id].conditions
return QuestOfferConditions()
def get_npc_knowledge(self, npc_id: str) -> List[str]:
"""
Get what an NPC knows about this quest.
Args:
npc_id: The NPC's identifier
Returns:
List of facts the NPC knows about the quest
"""
return self.npc_quest_knowledge.get(npc_id, [])
def get_completion_dialogue(self, npc_id: str) -> str:
"""
Get the completion dialogue for a specific NPC.
Args:
npc_id: The NPC's identifier
Returns:
Custom completion dialogue if defined, otherwise empty string
"""
return self.completion_dialogue.get(npc_id, "")
def can_npc_offer(self, npc_id: str) -> bool:
"""
Check if an NPC can offer this quest.
Args:
npc_id: The NPC's identifier
Returns:
True if NPC is in quest_giver_npc_ids
"""
return npc_id in self.quest_giver_npc_ids
def to_dict(self) -> Dict[str, Any]:
"""Serialize quest to dictionary."""
return {
"quest_id": self.quest_id,
"name": self.name,
"description": self.description,
"difficulty": self.difficulty.value,
"quest_giver_npc_ids": self.quest_giver_npc_ids,
"quest_giver_name": self.quest_giver_name,
"location_id": self.location_id,
"region_id": self.region_id,
"objectives": [obj.to_dict() for obj in self.objectives],
"rewards": self.rewards.to_dict(),
"offering_triggers": self.offering_triggers.to_dict(),
"npc_offer_dialogues": {
npc_id: dialogue.to_dict()
for npc_id, dialogue in self.npc_offer_dialogues.items()
},
"npc_quest_knowledge": self.npc_quest_knowledge,
"lore_context": self.lore_context.to_dict(),
"dialogue_templates": self.dialogue_templates.to_dict(),
"completion_dialogue": self.completion_dialogue,
"tags": self.tags,
}
def to_offer_dict(self) -> Dict[str, Any]:
"""
Serialize quest for offering UI display.
Returns a trimmed version suitable for displaying to players
when a quest is being offered.
Returns:
Dictionary with quest offer display data
"""
return {
"quest_id": self.quest_id,
"name": self.name,
"description": self.description,
"difficulty": self.difficulty.value,
"quest_giver_name": self.quest_giver_name,
"objectives": [
{"description": obj.description, "progress_text": obj.progress_text}
for obj in self.objectives
],
"rewards": {
"gold": self.rewards.gold,
"experience": self.rewards.experience,
"items": self.rewards.items,
},
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Quest':
"""Deserialize quest from dictionary."""
# Parse objectives
objectives = [
QuestObjective.from_dict(obj)
for obj in data.get("objectives", [])
]
# Parse rewards
rewards = QuestReward()
if data.get("rewards"):
rewards = QuestReward.from_dict(data["rewards"])
# Parse offering triggers
offering_triggers = QuestOfferingTriggers()
if data.get("offering_triggers"):
offering_triggers = QuestOfferingTriggers.from_dict(data["offering_triggers"])
# Parse NPC offer dialogues
npc_offer_dialogues = {}
for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items():
npc_offer_dialogues[npc_id] = NPCOfferDialogue.from_dict(dialogue_data)
# Parse lore context
lore_context = QuestLoreContext()
if data.get("lore_context"):
lore_context = QuestLoreContext.from_dict(data["lore_context"])
# Parse dialogue templates
dialogue_templates = QuestDialogueTemplates()
if data.get("dialogue_templates"):
dialogue_templates = QuestDialogueTemplates.from_dict(data["dialogue_templates"])
return cls(
quest_id=data["quest_id"],
name=data["name"],
description=data.get("description", ""),
difficulty=QuestDifficulty(data.get("difficulty", "easy")),
quest_giver_npc_ids=data.get("quest_giver_npc_ids", []),
quest_giver_name=data.get("quest_giver_name", ""),
location_id=data.get("location_id", ""),
region_id=data.get("region_id", ""),
objectives=objectives,
rewards=rewards,
offering_triggers=offering_triggers,
npc_offer_dialogues=npc_offer_dialogues,
npc_quest_knowledge=data.get("npc_quest_knowledge", {}),
lore_context=lore_context,
dialogue_templates=dialogue_templates,
completion_dialogue=data.get("completion_dialogue", {}),
tags=data.get("tags", []),
)
def __repr__(self) -> str:
"""String representation of the quest."""
return f"Quest({self.quest_id}, {self.name}, {self.difficulty.value})"
@dataclass
class CharacterQuestState:
"""
Tracks a character's progress on an active quest.
Stored in character's quest tracking data to persist progress
across sessions.
Attributes:
quest_id: The quest being tracked
status: Current quest status
accepted_at: ISO timestamp when quest was accepted
objectives_progress: Progress on each objective {objective_id: current_progress}
completed_at: ISO timestamp when quest was completed (if completed)
"""
quest_id: str
status: QuestStatus = QuestStatus.ACTIVE
accepted_at: str = ""
objectives_progress: Dict[str, int] = field(default_factory=dict)
completed_at: Optional[str] = None
def update_progress(self, objective_id: str, amount: int = 1) -> None:
"""
Update progress on an objective.
Args:
objective_id: The objective to update
amount: Amount to add to progress
"""
current = self.objectives_progress.get(objective_id, 0)
self.objectives_progress[objective_id] = current + amount
def get_progress(self, objective_id: str) -> int:
"""
Get current progress on an objective.
Args:
objective_id: The objective to check
Returns:
Current progress amount
"""
return self.objectives_progress.get(objective_id, 0)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"quest_id": self.quest_id,
"status": self.status.value,
"accepted_at": self.accepted_at,
"objectives_progress": self.objectives_progress,
"completed_at": self.completed_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CharacterQuestState':
"""Deserialize from dictionary."""
return cls(
quest_id=data["quest_id"],
status=QuestStatus(data.get("status", "active")),
accepted_at=data.get("accepted_at", ""),
objectives_progress=data.get("objectives_progress", {}),
completed_at=data.get("completed_at"),
)
def __repr__(self) -> str:
"""String representation."""
return f"CharacterQuestState({self.quest_id}, {self.status.value})"

View File

@@ -0,0 +1,181 @@
"""
Transaction Model
Tracks all gold transactions for audit and analytics purposes.
Includes shop purchases, sales, quest rewards, trades, etc.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any, Optional
from enum import Enum
class TransactionType(Enum):
"""Types of gold transactions."""
SHOP_PURCHASE = "shop_purchase"
SHOP_SALE = "shop_sale"
QUEST_REWARD = "quest_reward"
ENEMY_LOOT = "enemy_loot"
PLAYER_TRADE = "player_trade"
NPC_GIFT = "npc_gift"
SYSTEM_ADJUSTMENT = "system_adjustment"
@dataclass
class Transaction:
"""
Represents a gold transaction for audit logging.
Attributes:
transaction_id: Unique identifier for this transaction
transaction_type: Type of transaction (purchase, sale, reward, etc.)
character_id: Character involved in the transaction
session_id: Game session where transaction occurred (for cleanup on session delete)
amount: Gold amount (negative for expenses, positive for income)
description: Human-readable description of the transaction
timestamp: When the transaction occurred
metadata: Additional context-specific data
"""
transaction_id: str
transaction_type: TransactionType
character_id: str
session_id: Optional[str] = None # Session context for cleanup
amount: int = 0 # Negative for expenses, positive for income
description: str = ""
timestamp: datetime = field(default_factory=datetime.utcnow)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize transaction to dictionary for storage.
Returns:
Dictionary containing all transaction data
"""
return {
"transaction_id": self.transaction_id,
"transaction_type": self.transaction_type.value,
"character_id": self.character_id,
"session_id": self.session_id,
"amount": self.amount,
"description": self.description,
"timestamp": self.timestamp.isoformat(),
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
"""
Deserialize transaction from dictionary.
Args:
data: Dictionary containing transaction data
Returns:
Transaction instance
"""
return cls(
transaction_id=data["transaction_id"],
transaction_type=TransactionType(data["transaction_type"]),
character_id=data["character_id"],
session_id=data.get("session_id"),
amount=data["amount"],
description=data["description"],
timestamp=datetime.fromisoformat(data["timestamp"]),
metadata=data.get("metadata", {}),
)
@classmethod
def create_purchase(
cls,
transaction_id: str,
character_id: str,
shop_id: str,
item_id: str,
quantity: int,
total_cost: int,
session_id: Optional[str] = None
) -> 'Transaction':
"""
Factory method for creating a shop purchase transaction.
Args:
transaction_id: Unique transaction ID
character_id: Character making the purchase
shop_id: Shop where purchase was made
item_id: Item purchased
quantity: Number of items purchased
total_cost: Total gold spent
session_id: Optional session ID for cleanup tracking
Returns:
Transaction instance for the purchase
"""
return cls(
transaction_id=transaction_id,
transaction_type=TransactionType.SHOP_PURCHASE,
character_id=character_id,
session_id=session_id,
amount=-total_cost, # Negative because spending gold
description=f"Purchased {quantity}x {item_id} from {shop_id}",
metadata={
"shop_id": shop_id,
"item_id": item_id,
"quantity": quantity,
"unit_price": total_cost // quantity if quantity > 0 else 0,
}
)
@classmethod
def create_sale(
cls,
transaction_id: str,
character_id: str,
shop_id: str,
item_id: str,
item_name: str,
quantity: int,
total_earned: int,
session_id: Optional[str] = None
) -> 'Transaction':
"""
Factory method for creating a shop sale transaction.
Args:
transaction_id: Unique transaction ID
character_id: Character making the sale
shop_id: Shop where sale was made
item_id: Item sold
item_name: Display name of the item
quantity: Number of items sold
total_earned: Total gold earned
session_id: Optional session ID for cleanup tracking
Returns:
Transaction instance for the sale
"""
return cls(
transaction_id=transaction_id,
transaction_type=TransactionType.SHOP_SALE,
character_id=character_id,
session_id=session_id,
amount=total_earned, # Positive because receiving gold
description=f"Sold {quantity}x {item_name} to {shop_id}",
metadata={
"shop_id": shop_id,
"item_id": item_id,
"item_name": item_name,
"quantity": quantity,
"unit_price": total_earned // quantity if quantity > 0 else 0,
}
)
def __repr__(self) -> str:
"""String representation of the transaction."""
sign = "+" if self.amount >= 0 else ""
return (
f"Transaction({self.transaction_type.value}, "
f"{sign}{self.amount}g, {self.description})"
)

View File

@@ -354,8 +354,8 @@ class CharacterService:
""" """
Permanently delete a character from the database. Permanently delete a character from the database.
Also cleans up any game sessions associated with the character Also cleans up any game sessions, shop transactions, and other
to prevent orphaned sessions. associated data to prevent orphaned records.
Args: Args:
character_id: Character ID character_id: Character ID
@@ -375,6 +375,15 @@ class CharacterService:
if not character: if not character:
raise CharacterNotFound(f"Character not found: {character_id}") raise CharacterNotFound(f"Character not found: {character_id}")
# Clean up shop transactions for this character
from app.services.shop_service import get_shop_service
shop_service = get_shop_service()
deleted_transactions = shop_service.delete_transactions_by_character(character_id)
if deleted_transactions > 0:
logger.info("Cleaned up transactions for deleted character",
character_id=character_id,
transactions_deleted=deleted_transactions)
# Clean up associated sessions before deleting the character # Clean up associated sessions before deleting the character
# Local import to avoid circular dependency (session_service imports character_service) # Local import to avoid circular dependency (session_service imports character_service)
from app.services.session_service import get_session_service from app.services.session_service import get_session_service

View File

@@ -34,6 +34,8 @@ from app.services.combat_repository import (
get_combat_repository, get_combat_repository,
CombatRepository CombatRepository
) )
from app.services.quest_service import get_quest_service
from app.models.quest import ObjectiveType
from app.utils.logging import get_logger from app.utils.logging import get_logger
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -1259,7 +1261,12 @@ class CombatService:
xp_per_player = rewards.experience // max(1, len(player_combatants)) xp_per_player = rewards.experience // max(1, len(player_combatants))
gold_per_player = rewards.gold // max(1, len(player_combatants)) gold_per_player = rewards.gold // max(1, len(player_combatants))
for player in player_combatants: # Distribute items evenly among players
# For simplicity, give all items to each player in solo mode,
# or distribute round-robin in multiplayer
items_to_distribute = [Item.from_dict(item_dict) for item_dict in rewards.items]
for i, player in enumerate(player_combatants):
if session.is_solo(): if session.is_solo():
char_id = session.solo_character_id char_id = session.solo_character_id
else: else:
@@ -1276,6 +1283,18 @@ class CombatService:
# Add gold # Add gold
character.gold += gold_per_player character.gold += gold_per_player
# Distribute items
if session.is_solo():
# Solo mode: give all items to the character
for item in items_to_distribute:
character.add_item(item)
else:
# Multiplayer: distribute items round-robin
# Player i gets items at indices i, i+n, i+2n, etc.
for idx, item in enumerate(items_to_distribute):
if idx % len(player_combatants) == i:
character.add_item(item)
# Save character # Save character
self.character_service.update_character(character, user_id) self.character_service.update_character(character, user_id)
@@ -1290,6 +1309,9 @@ class CombatService:
items=len(rewards.items), items=len(rewards.items),
level_ups=rewards.level_ups) level_ups=rewards.level_ups)
# Update quest progress for kill objectives
self._update_quest_kill_progress(encounter, session, user_id)
return rewards return rewards
def _build_loot_context(self, encounter: CombatEncounter) -> LootContext: def _build_loot_context(self, encounter: CombatEncounter) -> LootContext:
@@ -1339,6 +1361,114 @@ class CombatService:
# Helper Methods # Helper Methods
# ========================================================================= # =========================================================================
def _update_quest_kill_progress(
self,
encounter: CombatEncounter,
session,
user_id: str
) -> None:
"""
Update quest progress for kill objectives based on defeated enemies.
Scans all defeated enemies in the encounter, identifies their types,
and updates any active quests that have kill objectives matching
those enemy types.
Args:
encounter: Completed combat encounter
session: Game session
user_id: User ID for character updates
"""
# Collect killed enemy types and counts
killed_enemy_types: Dict[str, int] = {}
for combatant in encounter.combatants:
if not combatant.is_player and combatant.is_dead():
enemy_id = combatant.combatant_id.split("_")[0]
enemy = self.enemy_loader.load_enemy(enemy_id)
if enemy:
# Use enemy_id as the type identifier (matches target_enemy_type in quests)
killed_enemy_types[enemy_id] = killed_enemy_types.get(enemy_id, 0) + 1
if not killed_enemy_types:
return # No enemies killed
# Get character
if session.is_solo():
char_id = session.solo_character_id
else:
# For multiplayer, we'd need to update all players
# For now, just handle solo
return
try:
character = self.character_service.get_character(char_id, user_id)
if not character:
return
# Get active quests
active_quests = getattr(character, 'active_quests', [])
if not active_quests:
return
quest_service = get_quest_service()
quest_states = getattr(character, 'quest_states', {})
updated = False
for quest_id in active_quests:
quest = quest_service.load_quest(quest_id)
if not quest:
continue
quest_state = quest_states.get(quest_id, {})
objectives_progress = quest_state.get('objectives_progress', {})
for objective in quest.objectives:
# Check if this is a kill objective with a matching enemy type
if objective.objective_type != ObjectiveType.KILL:
continue
target_enemy = objective.target_enemy_type
if not target_enemy or target_enemy not in killed_enemy_types:
continue
# Update progress for this objective
kill_count = killed_enemy_types[target_enemy]
current_progress = objectives_progress.get(objective.objective_id, 0)
new_progress = min(
current_progress + kill_count,
objective.required_progress
)
if new_progress > current_progress:
objectives_progress[objective.objective_id] = new_progress
updated = True
logger.info(
"Quest kill progress updated",
character_id=char_id,
quest_id=quest_id,
objective_id=objective.objective_id,
enemy_type=target_enemy,
kills=kill_count,
progress=f"{new_progress}/{objective.required_progress}"
)
# Update quest state
if quest_id in quest_states:
quest_states[quest_id]['objectives_progress'] = objectives_progress
# Save character if any quests were updated
if updated:
character.quest_states = quest_states
self.character_service.update_character(character, user_id)
except Exception as e:
logger.error(
"Failed to update quest kill progress",
char_id=char_id,
error=str(e)
)
def _create_combatant_from_character( def _create_combatant_from_character(
self, self,
character: Character character: Character

View File

@@ -124,6 +124,15 @@ class DatabaseInitService:
logger.error("Failed to initialize combat_rounds table", error=str(e)) logger.error("Failed to initialize combat_rounds table", error=str(e))
results['combat_rounds'] = False results['combat_rounds'] = False
# Initialize transactions table
try:
self.init_transactions_table()
results['transactions'] = True
logger.info("Transactions table initialized successfully")
except Exception as e:
logger.error("Failed to initialize transactions table", error=str(e))
results['transactions'] = False
success_count = sum(1 for v in results.values() if v) success_count = sum(1 for v in results.values() if v)
total_count = len(results) total_count = len(results)
@@ -1084,6 +1093,174 @@ class DatabaseInitService:
code=e.code) code=e.code)
raise raise
def init_transactions_table(self) -> bool:
"""
Initialize the transactions table for tracking gold transactions.
Table schema:
- transaction_id (string, required): Unique transaction identifier (UUID)
- transaction_type (string, required): Type (shop_purchase, shop_sale, quest_reward, etc.)
- character_id (string, required): Character involved in transaction
- session_id (string, optional): Game session where transaction occurred
- amount (integer, required): Gold amount (negative=expense, positive=income)
- description (string, required): Human-readable description
- timestamp (string, required): ISO timestamp when transaction occurred
- metadata (string, optional): JSON metadata for additional context
Indexes:
- idx_character_id: Character-based lookups
- idx_session_id: Session-based lookups (for cleanup on session delete)
- idx_character_id_timestamp: Character transaction history
- idx_transaction_type: Filter by type
- idx_timestamp: Date range queries
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'transactions'
logger.info("Initializing transactions table", table_id=table_id)
try:
# Check if table already exists
try:
self.tables_db.get_table(
database_id=self.database_id,
table_id=table_id
)
logger.info("Transactions table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Transactions table does not exist, creating...")
# Create table
logger.info("Creating transactions table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Transactions'
)
logger.info("Transactions table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='transaction_id',
column_type='string',
size=36, # UUID length
required=True
)
self._create_column(
table_id=table_id,
column_id='transaction_type',
column_type='string',
size=50, # TransactionType enum values
required=True
)
self._create_column(
table_id=table_id,
column_id='character_id',
column_type='string',
size=100,
required=True
)
self._create_column(
table_id=table_id,
column_id='session_id',
column_type='string',
size=100,
required=False # Optional - some transactions may not have a session
)
self._create_column(
table_id=table_id,
column_id='amount',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='description',
column_type='string',
size=500,
required=True
)
self._create_column(
table_id=table_id,
column_id='timestamp',
column_type='string',
size=50, # ISO timestamp format
required=True
)
self._create_column(
table_id=table_id,
column_id='metadata',
column_type='string',
size=2000, # JSON metadata
required=False
)
# Wait for columns to fully propagate
logger.info("Waiting for columns to propagate before creating indexes...")
time.sleep(2)
# Create indexes for efficient querying
self._create_index(
table_id=table_id,
index_id='idx_character_id',
index_type='key',
attributes=['character_id']
)
self._create_index(
table_id=table_id,
index_id='idx_session_id',
index_type='key',
attributes=['session_id']
)
self._create_index(
table_id=table_id,
index_id='idx_character_id_timestamp',
index_type='key',
attributes=['character_id', 'timestamp']
)
self._create_index(
table_id=table_id,
index_id='idx_transaction_type',
index_type='key',
attributes=['transaction_type']
)
self._create_index(
table_id=table_id,
index_id='idx_timestamp',
index_type='key',
attributes=['timestamp']
)
logger.info("Transactions table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize transactions table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def _create_column( def _create_column(
self, self,
table_id: str, table_id: str,

View File

@@ -0,0 +1,374 @@
"""
LoreService for retrieving contextual lore for NPC conversations.
This module provides an interface for lore retrieval that will be
implemented with Weaviate in Phase 6. For now, it provides a mock
implementation that returns embedded lore from quest definitions.
The service follows a three-tier knowledge hierarchy:
1. World Lore - Global history, mythology, kingdoms
2. Regional Lore - Local history, landmarks, rumors
3. NPC Persona - Individual NPC knowledge (already in NPC YAML)
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional, Protocol
import structlog
from app.models.quest import Quest, QuestLoreContext
from app.models.npc import NPC
logger = structlog.get_logger(__name__)
@dataclass
class LoreEntry:
"""
A single piece of lore information.
Attributes:
content: The lore text content
title: Optional title for the lore entry
knowledge_type: Type of knowledge (common, academic, secret)
source: Where this lore came from (world, regional, quest)
metadata: Additional metadata for filtering
"""
content: str
title: str = ""
knowledge_type: str = "common"
source: str = "quest"
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"content": self.content,
"title": self.title,
"knowledge_type": self.knowledge_type,
"source": self.source,
"metadata": self.metadata,
}
@dataclass
class LoreContext:
"""
Aggregated lore context for AI prompts.
Contains lore from multiple sources, filtered for the current
NPC and conversation context.
Attributes:
world_lore: Global world knowledge entries
regional_lore: Local/regional knowledge entries
quest_lore: Quest-specific lore entries
"""
world_lore: List[LoreEntry] = field(default_factory=list)
regional_lore: List[LoreEntry] = field(default_factory=list)
quest_lore: List[LoreEntry] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary for AI prompt injection."""
return {
"world": [entry.to_dict() for entry in self.world_lore],
"regional": [entry.to_dict() for entry in self.regional_lore],
"quest": [entry.to_dict() for entry in self.quest_lore],
}
def has_content(self) -> bool:
"""Check if any lore content is available."""
return bool(self.world_lore or self.regional_lore or self.quest_lore)
class LoreServiceInterface(ABC):
"""
Abstract interface for lore services.
This interface allows swapping between MockLoreService (current)
and WeaviateLoreService (Phase 6) without changing calling code.
"""
@abstractmethod
def get_lore_context(
self,
npc: NPC,
quest: Optional[Quest] = None,
topic: str = "",
region_id: str = "",
) -> LoreContext:
"""
Get lore context for an NPC conversation.
Args:
npc: The NPC in the conversation
quest: Optional quest being discussed
topic: Topic of conversation for semantic search
region_id: Region to filter lore by
Returns:
LoreContext with relevant lore entries
"""
pass
@abstractmethod
def filter_lore_for_npc(
self,
lore_entries: List[LoreEntry],
npc: NPC,
) -> List[LoreEntry]:
"""
Filter lore entries based on what an NPC would know.
Args:
lore_entries: Raw lore entries
npc: The NPC to filter for
Returns:
Filtered list of lore entries
"""
pass
class MockLoreService(LoreServiceInterface):
"""
Mock implementation of LoreService using embedded quest lore.
This implementation returns lore directly from quest YAML files
until Weaviate is implemented in Phase 6. It provides a working
lore system without external dependencies.
"""
# NPC roles that have access to academic knowledge
ACADEMIC_ROLES = ["scholar", "wizard", "sage", "librarian", "priest", "mage"]
# NPC roles that might know secrets
SECRET_KEEPER_ROLES = ["mayor", "noble", "spy", "elder", "priest"]
def __init__(self):
"""Initialize the mock lore service."""
logger.info("MockLoreService initialized")
def get_lore_context(
self,
npc: NPC,
quest: Optional[Quest] = None,
topic: str = "",
region_id: str = "",
) -> LoreContext:
"""
Get lore context for an NPC conversation.
For the mock implementation, this primarily returns quest
embedded lore and some static regional/world hints.
Args:
npc: The NPC in the conversation
quest: Optional quest being discussed
topic: Topic of conversation (unused in mock)
region_id: Region to filter by
Returns:
LoreContext with available lore
"""
context = LoreContext()
# Add quest-specific lore if a quest is provided
if quest and quest.lore_context:
quest_entries = self._extract_quest_lore(quest)
context.quest_lore = self.filter_lore_for_npc(quest_entries, npc)
# Add some mock regional lore based on NPC location
regional_entries = self._get_mock_regional_lore(npc.location_id, region_id)
context.regional_lore = self.filter_lore_for_npc(regional_entries, npc)
# Add world lore if NPC is knowledgeable
if npc.role in self.ACADEMIC_ROLES:
world_entries = self._get_mock_world_lore()
context.world_lore = self.filter_lore_for_npc(world_entries, npc)
logger.debug(
"Lore context built",
npc_id=npc.npc_id,
quest_lore_count=len(context.quest_lore),
regional_lore_count=len(context.regional_lore),
world_lore_count=len(context.world_lore),
)
return context
def filter_lore_for_npc(
self,
lore_entries: List[LoreEntry],
npc: NPC,
) -> List[LoreEntry]:
"""
Filter lore entries based on what an NPC would know.
Args:
lore_entries: Raw lore entries
npc: The NPC to filter for
Returns:
Filtered list of lore entries
"""
filtered = []
for entry in lore_entries:
knowledge_type = entry.knowledge_type
# Academic knowledge requires appropriate role
if knowledge_type == "academic":
if npc.role not in self.ACADEMIC_ROLES:
continue
# Secret knowledge requires special role or tag
if knowledge_type == "secret":
if npc.role not in self.SECRET_KEEPER_ROLES:
if "secret_keeper" not in npc.tags:
continue
filtered.append(entry)
return filtered
def _extract_quest_lore(self, quest: Quest) -> List[LoreEntry]:
"""
Extract lore entries from a quest's embedded lore context.
Args:
quest: The quest to extract lore from
Returns:
List of LoreEntry objects
"""
entries = []
lore = quest.lore_context
# Add backstory as a lore entry
if lore.backstory:
entries.append(LoreEntry(
content=lore.backstory,
title=f"Background: {quest.name}",
knowledge_type="common",
source="quest",
metadata={"quest_id": quest.quest_id},
))
# Add world connections
for connection in lore.world_connections:
entries.append(LoreEntry(
content=connection,
title="World Connection",
knowledge_type="common",
source="quest",
metadata={"quest_id": quest.quest_id},
))
# Add regional hints
for hint in lore.regional_hints:
entries.append(LoreEntry(
content=hint,
title="Local Knowledge",
knowledge_type="common",
source="quest",
metadata={"quest_id": quest.quest_id},
))
return entries
def _get_mock_regional_lore(
self,
location_id: str,
region_id: str,
) -> List[LoreEntry]:
"""
Get mock regional lore for a location.
This provides basic regional context until Weaviate is implemented.
Args:
location_id: Specific location
region_id: Region identifier
Returns:
List of LoreEntry objects
"""
entries = []
# Crossville regional lore
if "crossville" in location_id.lower() or region_id == "crossville":
entries.extend([
LoreEntry(
content="Crossville was founded two hundred years ago as a trading post.",
title="Crossville History",
knowledge_type="common",
source="regional",
),
LoreEntry(
content="The Old Mines were sealed fifty years ago after a tragic accident.",
title="The Old Mines",
knowledge_type="common",
source="regional",
),
LoreEntry(
content="The Thornwood family has led the village for three generations.",
title="Village Leadership",
knowledge_type="common",
source="regional",
),
])
return entries
def _get_mock_world_lore(self) -> List[LoreEntry]:
"""
Get mock world-level lore.
This provides basic world context until Weaviate is implemented.
Returns:
List of LoreEntry objects
"""
return [
LoreEntry(
content="The Five Kingdoms united against the Shadow Empire two hundred years ago in a great war.",
title="The Great War",
knowledge_type="academic",
source="world",
),
LoreEntry(
content="Magic flows from the ley lines that crisscross the land, concentrated at ancient sites.",
title="The Nature of Magic",
knowledge_type="academic",
source="world",
),
LoreEntry(
content="The ancient ruins predate human settlement and are believed to be from the First Age.",
title="First Age Ruins",
knowledge_type="academic",
source="world",
),
]
# Global singleton instance
_service_instance: Optional[LoreServiceInterface] = None
def get_lore_service() -> LoreServiceInterface:
"""
Get the global LoreService instance.
Currently returns MockLoreService. In Phase 6, this will
be updated to return WeaviateLoreService.
Returns:
Singleton LoreService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = MockLoreService()
return _service_instance

View File

@@ -0,0 +1,408 @@
"""
QuestEligibilityService for determining quest offering eligibility.
This service handles the core logic for determining which quests an NPC
can offer to a specific character, including:
- Finding quests where the NPC is a quest giver
- Filtering by character level and prerequisites
- Checking relationship and flag conditions
- Applying probability rolls based on location
"""
import random
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
import structlog
from app.models.quest import Quest, QuestLoreContext
from app.models.character import Character
from app.services.quest_service import get_quest_service
logger = structlog.get_logger(__name__)
@dataclass
class QuestOfferContext:
"""
Context for offering a specific quest during NPC conversation.
Contains all the information needed to inject quest offering
context into the AI prompt for natural dialogue generation.
Attributes:
quest: The quest being offered
offer_dialogue: NPC-specific dialogue for offering this quest
npc_quest_knowledge: Facts the NPC knows about this quest
lore_context: Embedded lore for AI context
narrative_hooks: Hints for natural conversation weaving
"""
quest: Quest
offer_dialogue: str
npc_quest_knowledge: List[str]
lore_context: QuestLoreContext
narrative_hooks: List[str]
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary for AI prompt injection."""
return {
"quest_id": self.quest.quest_id,
"quest_name": self.quest.name,
"quest_description": self.quest.description,
"offer_dialogue": self.offer_dialogue,
"npc_quest_knowledge": self.npc_quest_knowledge,
"lore_backstory": self.lore_context.backstory,
"narrative_hooks": self.narrative_hooks,
"rewards": {
"gold": self.quest.rewards.gold,
"experience": self.quest.rewards.experience,
},
}
@dataclass
class QuestEligibilityResult:
"""
Result of checking quest eligibility for an NPC conversation.
Attributes:
eligible_quests: Quests that can be offered
should_offer_quest: Whether to actually offer a quest (after probability roll)
selected_quest_context: Context for the quest to offer (if should_offer)
blocking_reasons: Why certain quests were filtered out
"""
eligible_quests: List[Quest] = field(default_factory=list)
should_offer_quest: bool = False
selected_quest_context: Optional[QuestOfferContext] = None
blocking_reasons: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"eligible_quest_count": len(self.eligible_quests),
"should_offer_quest": self.should_offer_quest,
"selected_quest": (
self.selected_quest_context.to_dict()
if self.selected_quest_context
else None
),
}
class QuestEligibilityService:
"""
Service for determining quest offering eligibility.
This is the core service that implements the quest-centric design:
given an NPC and a character, determine which quests can be offered
and whether an offer should be made based on probability.
"""
# Default probability weights by location type
DEFAULT_PROBABILITY_WEIGHTS = {
"tavern": 0.35,
"town": 0.25,
"shop": 0.20,
"wilderness": 0.05,
"dungeon": 0.10,
"road": 0.10,
}
# Maximum active quests a character can have
MAX_ACTIVE_QUESTS = 2
def __init__(self):
"""Initialize the eligibility service."""
self.quest_service = get_quest_service()
logger.info("QuestEligibilityService initialized")
def check_eligibility(
self,
npc_id: str,
character: Character,
location_type: str = "town",
location_id: str = "",
force_probability: Optional[float] = None,
) -> QuestEligibilityResult:
"""
Check which quests an NPC can offer to a character.
This is the main entry point for quest eligibility checking.
It performs the full pipeline:
1. Find quests where NPC is quest_giver
2. Filter by character eligibility
3. Apply probability roll
4. Build offer context if successful
Args:
npc_id: The NPC's identifier
character: The player character
location_type: Type of current location (for probability)
location_id: Specific location ID
force_probability: Override probability roll (for testing)
Returns:
QuestEligibilityResult with eligible quests and offer context
"""
result = QuestEligibilityResult()
# Check if character already has max quests
if len(character.active_quests) >= self.MAX_ACTIVE_QUESTS:
logger.debug(
"Character at max active quests",
character_id=character.character_id,
active_quests=len(character.active_quests),
)
result.blocking_reasons["max_quests"] = "Character already has maximum active quests"
return result
# Get quests this NPC can offer
npc_quests = self.quest_service.get_quests_for_npc(npc_id)
if not npc_quests:
logger.debug("NPC has no quests to offer", npc_id=npc_id)
return result
# Filter quests by eligibility
for quest in npc_quests:
eligible, reason = self._check_quest_eligibility(
quest, character, npc_id, location_id
)
if eligible:
result.eligible_quests.append(quest)
else:
result.blocking_reasons[quest.quest_id] = reason
if not result.eligible_quests:
logger.debug(
"No eligible quests after filtering",
npc_id=npc_id,
character_id=character.character_id,
)
return result
# Apply probability roll
probability = self._get_offer_probability(
result.eligible_quests, location_type, force_probability
)
roll = random.random()
if roll > probability:
logger.debug(
"Probability roll failed",
probability=probability,
roll=roll,
location_type=location_type,
)
return result
# Select a quest to offer
selected_quest = self._select_quest_to_offer(result.eligible_quests)
if not selected_quest:
return result
# Build offer context
result.should_offer_quest = True
result.selected_quest_context = self._build_offer_context(
selected_quest, npc_id
)
logger.info(
"Quest offer prepared",
quest_id=selected_quest.quest_id,
npc_id=npc_id,
character_id=character.character_id,
)
return result
def _check_quest_eligibility(
self,
quest: Quest,
character: Character,
npc_id: str,
location_id: str,
) -> tuple[bool, str]:
"""
Check if a specific quest is eligible for a character.
Args:
quest: The quest to check
character: The player character
npc_id: The NPC offering the quest
location_id: Current location ID
Returns:
Tuple of (is_eligible, reason_if_not)
"""
triggers = quest.offering_triggers
# Check character level
if character.level < triggers.min_character_level:
return False, f"Character level too low (need {triggers.min_character_level})"
if character.level > triggers.max_character_level:
return False, f"Character level too high (max {triggers.max_character_level})"
# Check prerequisite quests
# Note: We need to check completed_quests on character
# For now, check active_quests doesn't contain prereqs (they should be completed)
completed_quests = getattr(character, 'completed_quests', [])
for prereq_id in triggers.required_quests_completed:
if prereq_id not in completed_quests:
return False, f"Prerequisite quest not completed: {prereq_id}"
# Check quest is not already active
if quest.quest_id in character.active_quests:
return False, "Quest already active"
# Check quest is not already completed
if quest.quest_id in completed_quests:
return False, "Quest already completed"
# Check NPC-specific conditions
npc_conditions = quest.get_offer_conditions(npc_id)
npc_interaction = character.npc_interactions.get(npc_id, {})
# Check relationship level
relationship = npc_interaction.get("relationship_level", 50)
if relationship < npc_conditions.min_relationship:
return False, f"Relationship too low (need {npc_conditions.min_relationship})"
# Check required flags
custom_flags = npc_interaction.get("custom_flags", {})
for required_flag in npc_conditions.required_flags:
if not custom_flags.get(required_flag):
return False, f"Required flag not set: {required_flag}"
# Check forbidden flags
for forbidden_flag in npc_conditions.forbidden_flags:
if custom_flags.get(forbidden_flag):
return False, f"Forbidden flag is set: {forbidden_flag}"
return True, ""
def _get_offer_probability(
self,
eligible_quests: List[Quest],
location_type: str,
force_probability: Optional[float] = None,
) -> float:
"""
Calculate the probability of offering a quest.
Uses the highest probability weight among eligible quests
for the given location type.
Args:
eligible_quests: Quests that passed eligibility check
location_type: Type of current location
force_probability: Override value (for testing)
Returns:
Probability between 0.0 and 1.0
"""
if force_probability is not None:
return force_probability
# Find the highest probability weight for this location
max_probability = 0.0
for quest in eligible_quests:
quest_prob = quest.offering_triggers.get_probability(location_type)
if quest_prob > max_probability:
max_probability = quest_prob
# Fall back to default if no quest defines a probability
if max_probability == 0.0:
max_probability = self.DEFAULT_PROBABILITY_WEIGHTS.get(location_type, 0.10)
return max_probability
def _select_quest_to_offer(self, eligible_quests: List[Quest]) -> Optional[Quest]:
"""
Select which quest to offer from eligible quests.
Currently uses simple random selection, but could be enhanced
to use AI selection or priority weights.
Args:
eligible_quests: Quests that can be offered
Returns:
Selected quest or None
"""
if not eligible_quests:
return None
# Simple random selection for now
# TODO: Could use AI selection or difficulty-based priority
return random.choice(eligible_quests)
def _build_offer_context(
self,
quest: Quest,
npc_id: str,
) -> QuestOfferContext:
"""
Build the context for offering a quest.
Args:
quest: The quest being offered
npc_id: The NPC offering it
Returns:
QuestOfferContext with all necessary data for AI prompt
"""
return QuestOfferContext(
quest=quest,
offer_dialogue=quest.get_offer_dialogue(npc_id),
npc_quest_knowledge=quest.get_npc_knowledge(npc_id),
lore_context=quest.lore_context,
narrative_hooks=quest.dialogue_templates.narrative_hooks,
)
def get_available_quests_for_display(
self,
npc_id: str,
character: Character,
) -> List[Dict[str, Any]]:
"""
Get a list of available quests for UI display.
This is a simpler check that just returns eligible quests
without probability rolls, for showing in a quest list UI.
Args:
npc_id: The NPC's identifier
character: The player character
Returns:
List of quest display data
"""
result = self.check_eligibility(
npc_id=npc_id,
character=character,
force_probability=1.0, # Always include eligible quests
)
return [
quest.to_offer_dict()
for quest in result.eligible_quests
]
# Global singleton instance
_service_instance: Optional[QuestEligibilityService] = None
def get_quest_eligibility_service() -> QuestEligibilityService:
"""
Get the global QuestEligibilityService instance.
Returns:
Singleton QuestEligibilityService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = QuestEligibilityService()
return _service_instance

View File

@@ -0,0 +1,423 @@
"""
QuestService for loading quest definitions from YAML files.
This service reads quest configuration files and converts them into Quest
dataclass instances, providing caching for performance. Quests are organized
by difficulty subdirectories (easy, medium, hard, epic).
Follows the same pattern as NPCLoader for consistency.
"""
import yaml
from pathlib import Path
from typing import Dict, List, Optional
import structlog
from app.models.quest import (
Quest,
QuestObjective,
QuestReward,
QuestOfferingTriggers,
QuestOfferConditions,
NPCOfferDialogue,
QuestLoreContext,
QuestDialogueTemplates,
QuestDifficulty,
ObjectiveType,
)
logger = structlog.get_logger(__name__)
class QuestService:
"""
Loads quest definitions from YAML configuration files.
Quests are organized in difficulty subdirectories:
/app/data/quests/
easy/
cellar_rats.yaml
missing_item.yaml
medium/
bandit_threat.yaml
hard/
dungeon_depths.yaml
epic/
ancient_evil.yaml
This allows game designers to define quests without touching code.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the quest service.
Args:
data_dir: Path to directory containing quest YAML files.
Defaults to /app/data/quests/
"""
if data_dir is None:
# Default to app/data/quests relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "quests")
self.data_dir = Path(data_dir)
self._quest_cache: Dict[str, Quest] = {}
self._npc_quest_cache: Dict[str, List[str]] = {} # npc_id -> [quest_ids]
self._difficulty_cache: Dict[str, List[str]] = {} # difficulty -> [quest_ids]
logger.info("QuestService initialized", data_dir=str(self.data_dir))
def load_quest(self, quest_id: str) -> Optional[Quest]:
"""
Load a single quest by ID.
Searches all difficulty subdirectories for the quest file.
Args:
quest_id: Unique quest identifier (e.g., "quest_cellar_rats")
Returns:
Quest instance or None if not found
"""
# Check cache first
if quest_id in self._quest_cache:
logger.debug("Quest loaded from cache", quest_id=quest_id)
return self._quest_cache[quest_id]
# Search in difficulty subdirectories
if not self.data_dir.exists():
logger.error("Quest data directory does not exist", data_dir=str(self.data_dir))
return None
for difficulty_dir in self.data_dir.iterdir():
if not difficulty_dir.is_dir():
continue
# Try both with and without quest_ prefix
for filename in [f"{quest_id}.yaml", f"{quest_id.replace('quest_', '')}.yaml"]:
file_path = difficulty_dir / filename
if file_path.exists():
return self._load_quest_file(file_path)
logger.warning("Quest not found", quest_id=quest_id)
return None
def _load_quest_file(self, file_path: Path) -> Optional[Quest]:
"""
Load a quest from a specific file.
Args:
file_path: Path to the YAML file
Returns:
Quest instance or None if loading fails
"""
try:
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
quest = self._parse_quest_data(data)
self._quest_cache[quest.quest_id] = quest
# Update NPC-to-quest cache
for npc_id in quest.quest_giver_npc_ids:
if npc_id not in self._npc_quest_cache:
self._npc_quest_cache[npc_id] = []
if quest.quest_id not in self._npc_quest_cache[npc_id]:
self._npc_quest_cache[npc_id].append(quest.quest_id)
# Update difficulty cache
difficulty = quest.difficulty.value
if difficulty not in self._difficulty_cache:
self._difficulty_cache[difficulty] = []
if quest.quest_id not in self._difficulty_cache[difficulty]:
self._difficulty_cache[difficulty].append(quest.quest_id)
logger.info("Quest loaded successfully", quest_id=quest.quest_id)
return quest
except Exception as e:
logger.error("Failed to load quest", file=str(file_path), error=str(e))
return None
def _parse_quest_data(self, data: Dict) -> Quest:
"""
Parse YAML data into a Quest dataclass.
Args:
data: Dictionary loaded from YAML file
Returns:
Quest instance
Raises:
ValueError: If data is invalid or missing required fields
"""
# Validate required fields
required_fields = ["quest_id", "name"]
for field in required_fields:
if field not in data:
raise ValueError(f"Missing required field: {field}")
# Parse objectives
objectives = []
for obj_data in data.get("objectives", []):
objectives.append(QuestObjective(
objective_id=obj_data["objective_id"],
description=obj_data["description"],
objective_type=ObjectiveType(obj_data.get("objective_type", "kill")),
required_progress=obj_data.get("required_progress", 1),
current_progress=0,
target_enemy_type=obj_data.get("target_enemy_type"),
target_item_id=obj_data.get("target_item_id"),
target_location_id=obj_data.get("target_location_id"),
target_npc_id=obj_data.get("target_npc_id"),
))
# Parse rewards
rewards_data = data.get("rewards", {})
rewards = QuestReward(
gold=rewards_data.get("gold", 0),
experience=rewards_data.get("experience", 0),
items=rewards_data.get("items", []),
relationship_bonuses=rewards_data.get("relationship_bonuses", {}),
unlocks_quests=rewards_data.get("unlocks_quests", []),
reveals_locations=rewards_data.get("reveals_locations", []),
)
# Parse offering triggers
triggers_data = data.get("offering_triggers", {})
offering_triggers = QuestOfferingTriggers(
location_types=triggers_data.get("location_types", []),
specific_locations=triggers_data.get("specific_locations", []),
min_character_level=triggers_data.get("min_character_level", 1),
max_character_level=triggers_data.get("max_character_level", 100),
required_quests_completed=triggers_data.get("required_quests_completed", []),
probability_weights=triggers_data.get("probability_weights", {}),
)
# Parse NPC offer dialogues
npc_offer_dialogues = {}
for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items():
conditions_data = dialogue_data.get("conditions", {})
conditions = QuestOfferConditions(
min_relationship=conditions_data.get("min_relationship", 0),
required_flags=conditions_data.get("required_flags", []),
forbidden_flags=conditions_data.get("forbidden_flags", []),
)
npc_offer_dialogues[npc_id] = NPCOfferDialogue(
dialogue=dialogue_data.get("dialogue", ""),
conditions=conditions,
)
# Parse lore context
lore_data = data.get("lore_context", {})
lore_context = QuestLoreContext(
backstory=lore_data.get("backstory", ""),
world_connections=lore_data.get("world_connections", []),
regional_hints=lore_data.get("regional_hints", []),
)
# Parse dialogue templates
templates_data = data.get("dialogue_templates", {})
dialogue_templates = QuestDialogueTemplates(
narrative_hooks=templates_data.get("narrative_hooks", []),
ambient_hints=templates_data.get("ambient_hints", []),
)
return Quest(
quest_id=data["quest_id"],
name=data["name"],
description=data.get("description", ""),
difficulty=QuestDifficulty(data.get("difficulty", "easy")),
quest_giver_npc_ids=data.get("quest_giver_npc_ids", []),
quest_giver_name=data.get("quest_giver_name", ""),
location_id=data.get("location_id", ""),
region_id=data.get("region_id", ""),
objectives=objectives,
rewards=rewards,
offering_triggers=offering_triggers,
npc_offer_dialogues=npc_offer_dialogues,
npc_quest_knowledge=data.get("npc_quest_knowledge", {}),
lore_context=lore_context,
dialogue_templates=dialogue_templates,
completion_dialogue=data.get("completion_dialogue", {}),
tags=data.get("tags", []),
)
def load_all_quests(self) -> List[Quest]:
"""
Load all quests from all difficulty directories.
Returns:
List of Quest instances
"""
quests = []
if not self.data_dir.exists():
logger.error("Quest data directory does not exist", data_dir=str(self.data_dir))
return quests
for difficulty_dir in self.data_dir.iterdir():
if not difficulty_dir.is_dir():
continue
for file_path in difficulty_dir.glob("*.yaml"):
quest = self._load_quest_file(file_path)
if quest:
quests.append(quest)
logger.info("All quests loaded", count=len(quests))
return quests
def get_quests_for_npc(self, npc_id: str) -> List[Quest]:
"""
Get all quests that a specific NPC can offer.
This is the primary method for the quest-centric design:
quests define which NPCs can offer them, and this method
finds all quests for a given NPC.
Args:
npc_id: NPC identifier
Returns:
List of Quest instances that this NPC can offer
"""
# Ensure all quests are loaded
if not self._quest_cache:
self.load_all_quests()
quest_ids = self._npc_quest_cache.get(npc_id, [])
return [
self._quest_cache[quest_id]
for quest_id in quest_ids
if quest_id in self._quest_cache
]
def get_quests_by_difficulty(self, difficulty: str) -> List[Quest]:
"""
Get all quests of a specific difficulty.
Args:
difficulty: Difficulty level ("easy", "medium", "hard", "epic")
Returns:
List of Quest instances with this difficulty
"""
# Ensure all quests are loaded
if not self._quest_cache:
self.load_all_quests()
quest_ids = self._difficulty_cache.get(difficulty, [])
return [
self._quest_cache[quest_id]
for quest_id in quest_ids
if quest_id in self._quest_cache
]
def get_quests_for_location(self, location_id: str, location_type: str = "") -> List[Quest]:
"""
Get quests that can be offered at a specific location.
Args:
location_id: Location identifier
location_type: Type of location (e.g., "tavern", "town")
Returns:
List of Quest instances that can be offered here
"""
# Ensure all quests are loaded
if not self._quest_cache:
self.load_all_quests()
matching_quests = []
for quest in self._quest_cache.values():
triggers = quest.offering_triggers
# Check specific locations
if location_id in triggers.specific_locations:
matching_quests.append(quest)
continue
# Check location types
if location_type and location_type in triggers.location_types:
matching_quests.append(quest)
continue
return matching_quests
def get_all_quest_ids(self) -> List[str]:
"""
Get a list of all available quest IDs.
Returns:
List of quest IDs
"""
# Ensure all quests are loaded
if not self._quest_cache:
self.load_all_quests()
return list(self._quest_cache.keys())
def reload_quest(self, quest_id: str) -> Optional[Quest]:
"""
Force reload a quest from disk, bypassing cache.
Useful for development/testing when quest definitions change.
Args:
quest_id: Unique quest identifier
Returns:
Quest instance or None if not found
"""
# Remove from caches if present
if quest_id in self._quest_cache:
old_quest = self._quest_cache[quest_id]
# Remove from NPC cache
for npc_id in old_quest.quest_giver_npc_ids:
if npc_id in self._npc_quest_cache:
self._npc_quest_cache[npc_id] = [
qid for qid in self._npc_quest_cache[npc_id]
if qid != quest_id
]
# Remove from difficulty cache
difficulty = old_quest.difficulty.value
if difficulty in self._difficulty_cache:
self._difficulty_cache[difficulty] = [
qid for qid in self._difficulty_cache[difficulty]
if qid != quest_id
]
del self._quest_cache[quest_id]
return self.load_quest(quest_id)
def clear_cache(self) -> None:
"""Clear all cached data. Useful for testing."""
self._quest_cache.clear()
self._npc_quest_cache.clear()
self._difficulty_cache.clear()
logger.info("Quest cache cleared")
# Global singleton instance
_service_instance: Optional[QuestService] = None
def get_quest_service() -> QuestService:
"""
Get the global QuestService instance.
Returns:
Singleton QuestService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = QuestService()
return _service_instance

View File

@@ -421,7 +421,7 @@ class SessionService:
from the database. Use this when the user wants to free up their session from the database. Use this when the user wants to free up their session
slot and doesn't need to preserve the game history. slot and doesn't need to preserve the game history.
Also deletes all chat messages associated with this session. Also deletes all chat messages and shop transactions associated with this session.
Args: Args:
session_id: Session ID to delete session_id: Session ID to delete
@@ -439,7 +439,15 @@ class SessionService:
# Verify ownership first (raises SessionNotFound if invalid) # Verify ownership first (raises SessionNotFound if invalid)
self.get_session(session_id, user_id) self.get_session(session_id, user_id)
# Delete associated chat messages first # Delete associated shop transactions first
from app.services.shop_service import get_shop_service
shop_service = get_shop_service()
deleted_transactions = shop_service.delete_transactions_by_session(session_id)
logger.info("Deleted associated transactions",
session_id=session_id,
transaction_count=deleted_transactions)
# Delete associated chat messages
chat_service = get_chat_message_service() chat_service = get_chat_message_service()
deleted_messages = chat_service.delete_messages_by_session(session_id) deleted_messages = chat_service.delete_messages_by_session(session_id)
logger.info("Deleted associated chat messages", logger.info("Deleted associated chat messages",

View File

@@ -0,0 +1,642 @@
"""
Shop Service - NPC shop inventory and transactions.
This service manages NPC shop inventories and handles purchase/sell transactions.
Shop data is loaded from YAML files defining available items and their prices.
"""
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
import uuid
import yaml
import json
from app.models.items import Item
from app.models.character import Character
from app.models.enums import ItemRarity
from app.models.transaction import Transaction
from app.services.static_item_loader import get_static_item_loader, StaticItemLoader
from app.services.base_item_loader import get_base_item_loader, BaseItemLoader
from app.services.item_generator import get_item_generator, ItemGenerator
from app.services.database_service import get_database_service, DatabaseService
from app.utils.logging import get_logger
logger = get_logger(__file__)
class ShopNotFoundError(Exception):
"""Raised when a shop ID is not found."""
pass
class ItemNotInShopError(Exception):
"""Raised when an item is not available in the shop."""
pass
class InsufficientGoldError(Exception):
"""Raised when character doesn't have enough gold."""
pass
class ItemNotOwnedError(Exception):
"""Raised when trying to sell an item not in inventory."""
pass
class ShopService:
"""
Service for managing NPC shop operations.
Handles:
- Loading shop inventories from YAML
- Getting enriched inventory for UI (with can_afford flags)
- Processing purchases (validate gold, add item, deduct gold)
- Processing sales (validate ownership, remove item, add gold)
Shop items can reference:
- Static items (consumables, accessories) via StaticItemLoader
- Base item templates (weapons, armor) via ItemGenerator
"""
def __init__(
self,
data_dir: Optional[str] = None,
static_loader: Optional[StaticItemLoader] = None,
base_loader: Optional[BaseItemLoader] = None,
item_generator: Optional[ItemGenerator] = None,
database_service: Optional[DatabaseService] = None
):
"""
Initialize the shop service.
Args:
data_dir: Path to shop YAML files. Defaults to /app/data/shop/
static_loader: Optional custom StaticItemLoader instance
base_loader: Optional custom BaseItemLoader instance
item_generator: Optional custom ItemGenerator instance
database_service: Optional custom DatabaseService instance
"""
if data_dir is None:
current_file = Path(__file__)
app_dir = current_file.parent.parent
data_dir = str(app_dir / "data" / "shop")
self.data_dir = Path(data_dir)
self.static_loader = static_loader or get_static_item_loader()
self.base_loader = base_loader or get_base_item_loader()
self.item_generator = item_generator or get_item_generator()
self.database_service = database_service or get_database_service()
self._shop_cache: Dict[str, dict] = {}
self._loaded = False
logger.info("ShopService initialized", data_dir=str(self.data_dir))
def _save_transaction(self, transaction: Transaction) -> None:
"""
Persist a transaction to the database.
Args:
transaction: Transaction to save
"""
try:
# Convert transaction to database format
data = {
"transaction_id": transaction.transaction_id,
"transaction_type": transaction.transaction_type.value,
"character_id": transaction.character_id,
"session_id": transaction.session_id,
"amount": transaction.amount,
"description": transaction.description,
"timestamp": transaction.timestamp.isoformat(),
"metadata": json.dumps(transaction.metadata) if transaction.metadata else None,
}
self.database_service.create_row(
table_id="transactions",
data=data,
row_id=transaction.transaction_id
)
logger.info(
"Transaction saved to database",
transaction_id=transaction.transaction_id,
transaction_type=transaction.transaction_type.value
)
except Exception as e:
# Log error but don't fail the purchase/sale operation
logger.error(
"Failed to save transaction to database",
transaction_id=transaction.transaction_id,
error=str(e)
)
def _ensure_loaded(self) -> None:
"""Ensure shop data is loaded before any operation."""
if not self._loaded:
self._load_all_shops()
def _load_all_shops(self) -> None:
"""Load all shop YAML files from the data directory."""
if not self.data_dir.exists():
logger.warning("Shop data directory not found", path=str(self.data_dir))
self._loaded = True
return
for yaml_file in self.data_dir.glob("*.yaml"):
self._load_shop_file(yaml_file)
self._loaded = True
logger.info("Shops loaded", count=len(self._shop_cache))
def _load_shop_file(self, yaml_file: Path) -> None:
"""Load a single shop from YAML file."""
try:
with open(yaml_file, 'r') as f:
shop_data = yaml.safe_load(f)
if shop_data is None:
logger.warning("Empty shop YAML file", file=str(yaml_file))
return
shop_id = shop_data.get("shop_id")
if not shop_id:
logger.warning("Shop YAML missing shop_id", file=str(yaml_file))
return
self._shop_cache[shop_id] = shop_data
logger.debug("Shop loaded", shop_id=shop_id, file=str(yaml_file))
except Exception as e:
logger.error("Failed to load shop file", file=str(yaml_file), error=str(e))
def get_shop(self, shop_id: str) -> dict:
"""
Get shop data by ID.
Args:
shop_id: Shop identifier
Returns:
Shop data dictionary
Raises:
ShopNotFoundError: If shop doesn't exist
"""
self._ensure_loaded()
shop = self._shop_cache.get(shop_id)
if not shop:
raise ShopNotFoundError(f"Shop '{shop_id}' not found")
return shop
def _resolve_item(self, item_id: str) -> Optional[Item]:
"""
Resolve an item_id to an Item instance.
Tries static items first (consumables, accessories), then base templates.
Args:
item_id: Item identifier from shop inventory
Returns:
Item instance or None if not found
"""
# Try static item first (consumables, accessories, materials)
if self.static_loader.has_item(item_id):
return self.static_loader.get_item(item_id)
# Try base template (weapons, armor, shields)
template = self.base_loader.get_template(item_id)
if template:
# Generate a common-rarity item from the template
return self.item_generator.generate_item(
item_type=template.item_type,
rarity=ItemRarity.COMMON,
character_level=1,
base_template_id=item_id
)
logger.warning("Could not resolve shop item", item_id=item_id)
return None
def get_shop_inventory_for_character(
self,
shop_id: str,
character: Character
) -> dict:
"""
Get enriched shop inventory with character context.
This is the main method for the UI - returns everything needed
in a single call to avoid multiple API round-trips.
Args:
shop_id: Shop identifier
character: Character instance (for gold/can_afford calculations)
Returns:
Dictionary containing:
- shop: Shop metadata (id, name, shopkeeper)
- character: Character context (id, gold)
- inventory: List of enriched items with prices and can_afford flags
- categories: List of unique item type categories
Raises:
ShopNotFoundError: If shop doesn't exist
"""
shop = self.get_shop(shop_id)
enriched_inventory = []
categories = set()
for shop_item in shop.get("inventory", []):
item_id = shop_item.get("item_id")
price = shop_item.get("price", 0)
stock = shop_item.get("stock", -1) # -1 = unlimited
# Resolve the item
item = self._resolve_item(item_id)
if not item:
logger.warning(
"Skipping unresolved shop item",
shop_id=shop_id,
item_id=item_id
)
continue
# Track category
categories.add(item.item_type.value)
# Build enriched item entry
enriched_inventory.append({
"item": item.to_dict(),
"shop_price": price,
"stock": stock,
"can_afford": character.can_afford(price),
"item_id": item_id, # Original template ID for purchase requests
})
return {
"shop": {
"shop_id": shop.get("shop_id"),
"shop_name": shop.get("shop_name"),
"shop_description": shop.get("shop_description", ""),
"shopkeeper_name": shop.get("shopkeeper_name"),
"sell_rate": shop.get("sell_rate", 0.5),
},
"character": {
"character_id": character.character_id,
"gold": character.gold,
},
"inventory": enriched_inventory,
"categories": sorted(list(categories)),
}
def get_item_price(self, shop_id: str, item_id: str) -> int:
"""
Get the price of an item in a shop.
Args:
shop_id: Shop identifier
item_id: Item identifier
Returns:
Price in gold
Raises:
ShopNotFoundError: If shop doesn't exist
ItemNotInShopError: If item is not in the shop
"""
shop = self.get_shop(shop_id)
for shop_item in shop.get("inventory", []):
if shop_item.get("item_id") == item_id:
return shop_item.get("price", 0)
raise ItemNotInShopError(f"Item '{item_id}' not available in shop '{shop_id}'")
def purchase_item(
self,
character: Character,
shop_id: str,
item_id: str,
quantity: int = 1,
session_id: Optional[str] = None
) -> dict:
"""
Purchase an item from the shop.
Args:
character: Character making the purchase
shop_id: Shop identifier
item_id: Item to purchase (template ID)
quantity: Number of items to buy
session_id: Optional session ID for transaction tracking
Returns:
Dictionary containing:
- purchase: Transaction details (item_id, quantity, total_cost)
- character: Updated character context (gold)
- items_added: List of item IDs added to inventory
Raises:
ShopNotFoundError: If shop doesn't exist
ItemNotInShopError: If item is not in the shop
InsufficientGoldError: If character can't afford the purchase
"""
# Get price
price_per_item = self.get_item_price(shop_id, item_id)
total_cost = price_per_item * quantity
# Validate gold
if not character.can_afford(total_cost):
raise InsufficientGoldError(
f"Not enough gold. Need {total_cost}, have {character.gold}"
)
# Deduct gold
character.remove_gold(total_cost)
# Add items to inventory
items_added = []
for _ in range(quantity):
item = self._resolve_item(item_id)
if item:
character.add_item(item)
items_added.append(item.item_id)
# Create transaction record for audit logging
transaction = Transaction.create_purchase(
transaction_id=str(uuid.uuid4()),
character_id=character.character_id,
shop_id=shop_id,
item_id=item_id,
quantity=quantity,
total_cost=total_cost,
session_id=session_id
)
# Save transaction to database
self._save_transaction(transaction)
logger.info(
"Purchase completed",
character_id=character.character_id,
shop_id=shop_id,
item_id=item_id,
quantity=quantity,
total_cost=total_cost,
gold_remaining=character.gold,
transaction_id=transaction.transaction_id
)
return {
"purchase": {
"item_id": item_id,
"quantity": quantity,
"total_cost": total_cost,
},
"character": {
"character_id": character.character_id,
"gold": character.gold,
},
"items_added": items_added,
"transaction": transaction.to_dict(),
}
def get_sell_price(self, shop_id: str, item: Item) -> int:
"""
Calculate the sell-back price for an item.
Uses the shop's sell_rate (default 50%) of the item's value.
Args:
shop_id: Shop identifier
item: Item to sell
Returns:
Sell price in gold
"""
shop = self.get_shop(shop_id)
sell_rate = shop.get("sell_rate", 0.5)
# Use item's value as base
return int(item.value * sell_rate)
def sell_item(
self,
character: Character,
shop_id: str,
item_instance_id: str,
quantity: int = 1,
session_id: Optional[str] = None
) -> dict:
"""
Sell an item back to the shop.
Args:
character: Character selling the item
shop_id: Shop identifier
item_instance_id: Unique item instance ID from character inventory
quantity: Number of items to sell (for stackable items, future use)
session_id: Optional session ID for transaction tracking
Returns:
Dictionary containing:
- sale: Transaction details (item_id, item_name, quantity, total_earned)
- character: Updated character context (gold)
Raises:
ShopNotFoundError: If shop doesn't exist
ItemNotOwnedError: If item is not in character inventory
"""
# Validate shop exists
self.get_shop(shop_id)
# Find item in character inventory
item = None
for inv_item in character.inventory:
if inv_item.item_id == item_instance_id:
item = inv_item
break
if not item:
raise ItemNotOwnedError(
f"Item '{item_instance_id}' not found in inventory"
)
# Calculate sell price
sell_price = self.get_sell_price(shop_id, item)
total_earned = sell_price * quantity
# Remove item from inventory and add gold
removed_item = character.remove_item(item_instance_id)
if removed_item:
character.add_gold(total_earned)
# Create transaction record for audit logging
transaction = Transaction.create_sale(
transaction_id=str(uuid.uuid4()),
character_id=character.character_id,
shop_id=shop_id,
item_id=item_instance_id,
item_name=item.get_display_name(),
quantity=quantity,
total_earned=total_earned,
session_id=session_id
)
# Save transaction to database
self._save_transaction(transaction)
logger.info(
"Sale completed",
character_id=character.character_id,
shop_id=shop_id,
item_id=item_instance_id,
item_name=item.name,
quantity=quantity,
total_earned=total_earned,
gold_remaining=character.gold,
transaction_id=transaction.transaction_id
)
return {
"sale": {
"item_id": item_instance_id,
"item_name": item.get_display_name(),
"quantity": quantity,
"total_earned": total_earned,
},
"character": {
"character_id": character.character_id,
"gold": character.gold,
},
"transaction": transaction.to_dict(),
}
raise ItemNotOwnedError(f"Failed to remove item '{item_instance_id}'")
def delete_transactions_by_character(self, character_id: str) -> int:
"""
Delete all transactions for a character.
Used during character deletion to clean up transaction records.
Args:
character_id: Character ID to delete transactions for
Returns:
Number of transactions deleted
"""
try:
logger.info("Deleting transactions for character", character_id=character_id)
# Query all transactions for this character
from appwrite.query import Query
documents = self.database_service.list_rows(
table_id="transactions",
queries=[Query.equal('character_id', character_id)]
)
if not documents:
logger.debug("No transactions found for character", character_id=character_id)
return 0
deleted_count = 0
for document in documents:
try:
self.database_service.delete_row(
table_id="transactions",
row_id=document['$id']
)
deleted_count += 1
except Exception as e:
logger.error("Failed to delete transaction",
transaction_id=document.get('transaction_id'),
error=str(e))
continue
logger.info("Transactions deleted for character",
character_id=character_id,
deleted_count=deleted_count)
return deleted_count
except Exception as e:
logger.error("Failed to delete transactions for character",
character_id=character_id,
error=str(e))
return 0
def delete_transactions_by_session(self, session_id: str) -> int:
"""
Delete all transactions for a session.
Used during session deletion to clean up session-specific transaction records.
Args:
session_id: Session ID to delete transactions for
Returns:
Number of transactions deleted
"""
try:
logger.info("Deleting transactions for session", session_id=session_id)
# Query all transactions for this session
from appwrite.query import Query
documents = self.database_service.list_rows(
table_id="transactions",
queries=[Query.equal('session_id', session_id)]
)
if not documents:
logger.debug("No transactions found for session", session_id=session_id)
return 0
deleted_count = 0
for document in documents:
try:
self.database_service.delete_row(
table_id="transactions",
row_id=document['$id']
)
deleted_count += 1
except Exception as e:
logger.error("Failed to delete transaction",
transaction_id=document.get('transaction_id'),
error=str(e))
continue
logger.info("Transactions deleted for session",
session_id=session_id,
deleted_count=deleted_count)
return deleted_count
except Exception as e:
logger.error("Failed to delete transactions for session",
session_id=session_id,
error=str(e))
return 0
# Global instance for convenience
_service_instance: Optional[ShopService] = None
def get_shop_service() -> ShopService:
"""
Get the global ShopService instance.
Returns:
Singleton ShopService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = ShopService()
return _service_instance

View File

@@ -26,6 +26,7 @@ Usage:
""" """
import json import json
import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
@@ -683,6 +684,7 @@ def _process_npc_dialogue_task(
- npc_relationship: Optional relationship description - npc_relationship: Optional relationship description
- previous_dialogue: Optional list of previous exchanges - previous_dialogue: Optional list of previous exchanges
- npc_knowledge: Optional list of things NPC knows - npc_knowledge: Optional list of things NPC knows
- quest_offering_context: Optional quest offer context from eligibility check
session_id: Game session ID session_id: Game session ID
character_id: Character ID character_id: Character ID
@@ -710,7 +712,10 @@ def _process_npc_dialogue_task(
user_tier=user_tier, user_tier=user_tier,
npc_relationship=context.get('npc_relationship'), npc_relationship=context.get('npc_relationship'),
previous_dialogue=context.get('previous_dialogue'), previous_dialogue=context.get('previous_dialogue'),
npc_knowledge=context.get('npc_knowledge') npc_knowledge=context.get('npc_knowledge'),
quest_offering_context=context.get('quest_offering_context'),
quest_ineligibility_context=context.get('quest_ineligibility_context'),
player_asking_for_quests=context.get('player_asking_for_quests', False)
) )
# Get NPC info for result # Get NPC info for result
@@ -721,8 +726,44 @@ def _process_npc_dialogue_task(
# Get previous dialogue for display (before adding new exchange) # Get previous dialogue for display (before adding new exchange)
previous_dialogue = context.get('previous_dialogue', []) previous_dialogue = context.get('previous_dialogue', [])
# Parse for quest offer marker and extract structured data for UI
dialogue_text = response.narrative
quest_offer_data = None
quest_offer_match = re.search(r'\[QUEST_OFFER:([^\]]+)\]', dialogue_text)
if quest_offer_match:
offered_quest_id = quest_offer_match.group(1).strip()
# Remove the marker from displayed dialogue (clean up whitespace around it)
dialogue_text = re.sub(r'\s*\[QUEST_OFFER:[^\]]+\]\s*', ' ', dialogue_text).strip()
# Get quest details from the offering context for UI display
quest_ctx = context.get('quest_offering_context')
if quest_ctx:
quest_offer_data = {
'quest_id': offered_quest_id,
'quest_name': quest_ctx.get('quest_name', 'Unknown Quest'),
'quest_description': quest_ctx.get('quest_description', ''),
'rewards': quest_ctx.get('rewards', {}),
'npc_id': npc_id,
}
logger.info(
"Quest offer detected in NPC dialogue",
quest_id=offered_quest_id,
quest_name=quest_ctx.get('quest_name'),
npc_id=npc_id,
character_id=character_id
)
else:
# AI mentioned a quest but no context was provided - log warning
logger.warning(
"Quest offer marker found but no quest_offering_context available",
quest_id=offered_quest_id,
npc_id=npc_id
)
result = { result = {
"dialogue": response.narrative, "dialogue": dialogue_text,
"tokens_used": response.tokens_used, "tokens_used": response.tokens_used,
"model": response.model, "model": response.model,
"context_type": response.context_type, "context_type": response.context_type,
@@ -732,6 +773,7 @@ def _process_npc_dialogue_task(
"character_name": character_name, "character_name": character_name,
"player_line": context['conversation_topic'], "player_line": context['conversation_topic'],
"conversation_history": previous_dialogue, # History before this exchange "conversation_history": previous_dialogue, # History before this exchange
"quest_offer": quest_offer_data, # Structured quest offer for UI (None if no quest offered)
} }
# Save dialogue exchange to chat_messages collection and update character's recent_messages cache # Save dialogue exchange to chat_messages collection and update character's recent_messages cache
@@ -741,15 +783,16 @@ def _process_npc_dialogue_task(
location_id = context.get('game_state', {}).get('current_location') location_id = context.get('game_state', {}).get('current_location')
# Save to chat_messages collection (also updates character's recent_messages) # Save to chat_messages collection (also updates character's recent_messages)
# Note: Save the cleaned dialogue_text (without quest markers) for display
chat_service = get_chat_message_service() chat_service = get_chat_message_service()
chat_service.save_dialogue_exchange( chat_service.save_dialogue_exchange(
character_id=character_id, character_id=character_id,
user_id=user_id, user_id=user_id,
npc_id=npc_id, npc_id=npc_id,
player_message=context['conversation_topic'], player_message=context['conversation_topic'],
npc_response=response.narrative, npc_response=dialogue_text, # Use cleaned text without quest markers
context=MessageContext.DIALOGUE, # Default context, can be enhanced based on quest/shop interactions context=MessageContext.DIALOGUE, # Default context, can be enhanced based on quest/shop interactions
metadata={}, # Can add quest_id, item_id, etc. when those systems are implemented metadata={'quest_offer_id': quest_offer_data.get('quest_id') if quest_offer_data else None},
session_id=session_id, session_id=session_id,
location_id=location_id location_id=location_id
) )

View File

@@ -936,7 +936,8 @@ power = fireball.calculate_power(caster_stats)
| `name` | str | Character name | | `name` | str | Character name |
| `player_class` | PlayerClass | Character class | | `player_class` | PlayerClass | Character class |
| `level` | int | Current level | | `level` | int | Current level |
| `experience` | int | XP points | | `experience` | int | Current XP progress toward next level (resets on level-up) |
| `total_xp` | int | Cumulative XP earned across all levels (never decreases) |
| `stats` | Stats | Current stats | | `stats` | Stats | Current stats |
| `unlocked_skills` | List[str] | Unlocked skill_ids | | `unlocked_skills` | List[str] | Unlocked skill_ids |
| `inventory` | List[Item] | All items | | `inventory` | List[Item] | All items |
@@ -945,10 +946,17 @@ power = fireball.calculate_power(caster_stats)
| `active_quests` | List[str] | Quest IDs | | `active_quests` | List[str] | Quest IDs |
| `discovered_locations` | List[str] | Location IDs | | `discovered_locations` | List[str] | Location IDs |
**Computed Properties:**
- `xp_to_next_level` - XP remaining until next level (calculated: `required_xp - experience`)
- `current_hp` / `max_hp` - Health points (calculated from constitution)
- `defense` / `resistance` - Damage reduction (calculated from stats)
**Methods:** **Methods:**
- `to_dict()` - Serialize to dictionary for JSON storage - `to_dict()` - Serialize to dictionary for JSON storage (includes computed fields)
- `from_dict(data)` - Deserialize from dictionary - `from_dict(data)` - Deserialize from dictionary (handles legacy data)
- `get_effective_stats(active_effects)` - **THE CRITICAL METHOD** - Calculate final stats - `get_effective_stats(active_effects)` - **THE CRITICAL METHOD** - Calculate final stats
- `add_experience(xp)` - Add XP and check for level-up (updates both `experience` and `total_xp`)
- `level_up()` - Level up character (resets `experience`, preserves `total_xp`)
**get_effective_stats() Details:** **get_effective_stats() Details:**

View File

@@ -401,6 +401,12 @@ XP Required = 100 × (current_level ^ 1.5)
- Level up triggers automatically when threshold reached - Level up triggers automatically when threshold reached
- Base stats remain constant (progression via skill trees & equipment) - Base stats remain constant (progression via skill trees & equipment)
**XP Tracking:**
- **`experience`**: Current progress toward next level (0 to required XP, resets on level-up)
- **`total_xp`**: Cumulative XP earned across all levels (never decreases)
- **UI Display**: Shows both values (e.g., "Total XP: 150 | Progress: 50/282 to Level 3")
- **Legacy data**: Characters without `total_xp` default to 0, will track from next XP gain
**Implementation:** **Implementation:**
- Leveling logic lives in `Character` model (`add_experience()`, `level_up()` methods) - Leveling logic lives in `Character` model (`add_experience()`, `level_up()` methods)
- No separate service needed (OOP design pattern) - No separate service needed (OOP design pattern)

View File

@@ -324,6 +324,7 @@ def test_add_experience_no_level_up(basic_character):
assert leveled_up == False assert leveled_up == False
assert basic_character.level == 1 assert basic_character.level == 1
assert basic_character.experience == 50 assert basic_character.experience == 50
assert basic_character.total_xp == 50 # Cumulative XP tracked
def test_add_experience_with_level_up(basic_character): def test_add_experience_with_level_up(basic_character):
@@ -333,7 +334,8 @@ def test_add_experience_with_level_up(basic_character):
assert leveled_up == True assert leveled_up == True
assert basic_character.level == 2 assert basic_character.level == 2
assert basic_character.experience == 0 # Reset assert basic_character.experience == 0 # Current level progress resets
assert basic_character.total_xp == 100 # Cumulative XP preserved
def test_add_experience_with_overflow(basic_character): def test_add_experience_with_overflow(basic_character):
@@ -343,7 +345,48 @@ def test_add_experience_with_overflow(basic_character):
assert leveled_up == True assert leveled_up == True
assert basic_character.level == 2 assert basic_character.level == 2
assert basic_character.experience == 50 # Overflow assert basic_character.experience == 50 # Overflow preserved
assert basic_character.total_xp == 150 # All XP tracked cumulatively
def test_xp_to_next_level_property(basic_character):
"""Test xp_to_next_level property calculation."""
# At level 1 with 0 XP, need 100 to level up
assert basic_character.xp_to_next_level == 100
# Add 30 XP
basic_character.add_experience(30)
assert basic_character.xp_to_next_level == 70 # 100 - 30
# Add 70 more to level up
basic_character.add_experience(70)
assert basic_character.level == 2
assert basic_character.experience == 0
# At level 2, need 282 XP to level up
assert basic_character.xp_to_next_level == 282
def test_total_xp_never_decreases(basic_character):
"""Test that total_xp is cumulative and never decreases."""
# Start at 0
assert basic_character.total_xp == 0
# Add XP multiple times
basic_character.add_experience(50)
assert basic_character.total_xp == 50
basic_character.add_experience(50)
assert basic_character.total_xp == 100
# Should have leveled up to level 2
assert basic_character.level == 2
assert basic_character.experience == 0 # Current progress reset
# Add more XP
basic_character.add_experience(100)
assert basic_character.total_xp == 200 # Still cumulative
# Even though experience resets on level-up, total_xp keeps growing
assert basic_character.total_xp >= basic_character.experience
def test_xp_calculation(basic_origin): def test_xp_calculation(basic_origin):
@@ -386,6 +429,7 @@ def test_character_serialization(basic_character):
basic_character.gold = 500 basic_character.gold = 500
basic_character.level = 3 basic_character.level = 3
basic_character.experience = 100 basic_character.experience = 100
basic_character.total_xp = 500 # Manually set for test
data = basic_character.to_dict() data = basic_character.to_dict()
@@ -394,7 +438,13 @@ def test_character_serialization(basic_character):
assert data["name"] == "Test Hero" assert data["name"] == "Test Hero"
assert data["level"] == 3 assert data["level"] == 3
assert data["experience"] == 100 assert data["experience"] == 100
assert data["total_xp"] == 500
assert data["gold"] == 500 assert data["gold"] == 500
# Check computed fields
assert "xp_to_next_level" in data
assert "xp_required_for_next_level" in data
assert data["xp_required_for_next_level"] == 519 # Level 3 requires 519 XP
assert data["xp_to_next_level"] == 419 # 519 - 100
def test_character_deserialization(basic_player_class, basic_origin): def test_character_deserialization(basic_player_class, basic_origin):

View File

@@ -636,7 +636,15 @@ class TestRewardsCalculation:
# Mock loot service to return mock items # Mock loot service to return mock items
mock_item = Mock() mock_item = Mock()
mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1} mock_item.to_dict.return_value = {
"item_id": "test_sword",
"name": "Test Sword",
"item_type": "weapon",
"rarity": "common",
"description": "A test sword",
"value": 10,
"damage": 5,
}
service.loot_service.generate_loot_from_enemy.return_value = [mock_item] service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
mock_session = Mock() mock_session = Mock()
@@ -647,6 +655,7 @@ class TestRewardsCalculation:
mock_char.level = 1 mock_char.level = 1
mock_char.experience = 0 mock_char.experience = 0
mock_char.gold = 0 mock_char.gold = 0
mock_char.add_item = Mock() # Mock the add_item method
service.character_service.get_character.return_value = mock_char service.character_service.get_character.return_value = mock_char
service.character_service.update_character = Mock() service.character_service.update_character = Mock()
@@ -655,3 +664,6 @@ class TestRewardsCalculation:
assert rewards.experience == 50 assert rewards.experience == 50
assert rewards.gold == 25 assert rewards.gold == 25
assert len(rewards.items) == 1 assert len(rewards.items) == 1
# Verify items were added to character inventory
assert mock_char.add_item.called, "Items should be added to character inventory"
assert mock_char.add_item.call_count == 1, "Should add 1 item to inventory"

View File

@@ -0,0 +1,602 @@
"""
Integration tests for Quest Offer Flow.
Tests the end-to-end quest offering pipeline:
- NPC talk endpoint → eligibility check → AI context → response parsing
These tests verify the wiring between components, not the individual
component logic (which is tested in unit tests).
"""
import pytest
from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from app.ai.response_parser import parse_npc_dialogue, ParsedNPCDialogue
from app.services.quest_eligibility_service import (
QuestEligibilityService,
QuestOfferContext,
QuestEligibilityResult,
get_quest_eligibility_service,
)
from app.models.quest import (
Quest,
QuestReward,
QuestObjective,
QuestOfferingTriggers,
QuestLoreContext,
QuestDialogueTemplates,
QuestDifficulty,
ObjectiveType,
)
from app.api.npcs import _get_location_type
class TestQuestOfferMarkerParsing:
"""Tests for [QUEST_OFFER:quest_id] marker extraction."""
def test_extracts_quest_id_from_dialogue(self):
"""parse_npc_dialogue extracts quest_id from [QUEST_OFFER:] marker."""
response = "*leans in* Got a job for ye. [QUEST_OFFER:quest_cellar_rats]"
result = parse_npc_dialogue(response)
assert result.quest_offered == "quest_cellar_rats"
assert "[QUEST_OFFER" not in result.dialogue
assert "Got a job for ye" in result.dialogue
def test_extracts_quest_id_multiline(self):
"""parse_npc_dialogue handles multiline responses with marker."""
response = """*scratches beard* The rats have been getting worse...
[QUEST_OFFER:quest_cellar_rats]
Think you could help me out?"""
result = parse_npc_dialogue(response)
assert result.quest_offered == "quest_cellar_rats"
assert "[QUEST_OFFER" not in result.dialogue
assert "The rats have been getting worse" in result.dialogue
assert "help me out" in result.dialogue
def test_no_quest_marker_returns_none(self):
"""parse_npc_dialogue returns None when no marker present."""
response = "*waves* Welcome to my shop! What can I do for you?"
result = parse_npc_dialogue(response)
assert result.quest_offered is None
assert result.dialogue == response
def test_handles_whitespace_in_marker(self):
"""parse_npc_dialogue handles whitespace in marker."""
response = "[QUEST_OFFER: quest_with_spaces ] Some dialogue here."
result = parse_npc_dialogue(response)
assert result.quest_offered == "quest_with_spaces"
def test_preserves_raw_response(self):
"""parse_npc_dialogue preserves original response in raw_response."""
response = "*nods* [QUEST_OFFER:quest_test] Indeed."
result = parse_npc_dialogue(response)
assert result.raw_response == response
assert "[QUEST_OFFER" in result.raw_response
assert "[QUEST_OFFER" not in result.dialogue
class TestLocationTypeExtraction:
"""Tests for _get_location_type helper function."""
def test_tavern_detection(self):
"""_get_location_type identifies tavern locations."""
assert _get_location_type("crossville_tavern") == "tavern"
assert _get_location_type("rusty_anchor_inn") == "tavern"
assert _get_location_type("VILLAGE_TAVERN") == "tavern"
def test_shop_detection(self):
"""_get_location_type identifies shop locations."""
assert _get_location_type("crossville_shop") == "shop"
assert _get_location_type("market_square") == "shop"
assert _get_location_type("general_store") == "shop"
def test_wilderness_detection(self):
"""_get_location_type identifies wilderness locations."""
assert _get_location_type("dark_forest") == "wilderness"
assert _get_location_type("mountain_road") == "wilderness"
assert _get_location_type("northern_wilderness") == "wilderness"
def test_dungeon_detection(self):
"""_get_location_type identifies dungeon locations."""
assert _get_location_type("goblin_cave") == "dungeon"
assert _get_location_type("ancient_dungeon") == "dungeon"
assert _get_location_type("abandoned_mine") == "dungeon"
def test_defaults_to_town(self):
"""_get_location_type defaults to town for unknown locations."""
assert _get_location_type("crossville_town_center") == "town"
assert _get_location_type("village_square") == "town"
assert _get_location_type("unknown_location") == "town"
class TestQuestOfferContextSerialization:
"""Tests for QuestOfferContext.to_dict() output."""
def test_to_dict_includes_required_fields(self):
"""QuestOfferContext.to_dict() includes all fields needed by template."""
quest = Quest(
quest_id="quest_test",
name="Test Quest",
description="A test quest",
difficulty=QuestDifficulty.EASY,
quest_giver_npc_ids=["npc_test"],
rewards=QuestReward(gold=50, experience=100),
)
context = QuestOfferContext(
quest=quest,
offer_dialogue="Got a problem, friend.",
npc_quest_knowledge=["The rats are big", "They came from tunnels"],
lore_context=QuestLoreContext(backstory="Old tunnels reopened"),
narrative_hooks=["looks worried", "glances at cellar door"],
)
data = context.to_dict()
# Check all required fields for template
assert data["quest_id"] == "quest_test"
assert data["quest_name"] == "Test Quest"
assert data["quest_description"] == "A test quest"
assert data["offer_dialogue"] == "Got a problem, friend."
assert "The rats are big" in data["npc_quest_knowledge"]
assert "looks worried" in data["narrative_hooks"]
assert data["lore_backstory"] == "Old tunnels reopened"
assert data["rewards"]["gold"] == 50
assert data["rewards"]["experience"] == 100
class TestQuestEligibilityServiceIntegration:
"""Tests for QuestEligibilityService integration."""
@pytest.fixture
def mock_character(self):
"""Create a mock character for testing."""
char = MagicMock()
char.level = 3
char.active_quests = []
char.completed_quests = []
char.npc_interactions = {
"npc_test": {
"relationship_level": 50,
"custom_flags": {},
}
}
return char
@pytest.fixture
def sample_quest(self):
"""Create a sample quest for testing."""
return Quest(
quest_id="quest_cellar_rats",
name="Rat Problem",
description="Clear the cellar of rats",
difficulty=QuestDifficulty.EASY,
quest_giver_npc_ids=["npc_grom"],
objectives=[
QuestObjective(
objective_id="kill_rats",
description="Kill 10 rats",
objective_type=ObjectiveType.KILL,
required_progress=10,
target_enemy_type="giant_rat",
)
],
rewards=QuestReward(gold=50, experience=100),
offering_triggers=QuestOfferingTriggers(
location_types=["tavern", "town"],
min_character_level=1,
max_character_level=10,
probability_weights={"tavern": 0.35, "town": 0.25},
),
lore_context=QuestLoreContext(
backstory="Rats came from old tunnels",
),
dialogue_templates=QuestDialogueTemplates(
narrative_hooks=["glances at cellar door"],
),
)
def test_no_quest_when_max_active(self, mock_character):
"""No quest offered when character has MAX_ACTIVE_QUESTS."""
mock_character.active_quests = ["quest_1", "quest_2"]
service = QuestEligibilityService()
# Mock the quest service to return a quest
with patch.object(service.quest_service, 'get_quests_for_npc', return_value=[]):
result = service.check_eligibility(
npc_id="npc_test",
character=mock_character,
location_type="tavern",
)
assert not result.should_offer_quest
assert result.selected_quest_context is None
def test_eligibility_result_structure(self, mock_character, sample_quest):
"""QuestEligibilityResult has correct structure."""
service = QuestEligibilityService()
# Mock to return a quest and force probability roll to succeed
with patch.object(service.quest_service, 'get_quests_for_npc', return_value=[sample_quest]):
result = service.check_eligibility(
npc_id="npc_grom",
character=mock_character,
location_type="tavern",
force_probability=1.0, # Force offer
)
# Verify result structure
assert isinstance(result.eligible_quests, list)
assert isinstance(result.should_offer_quest, bool)
assert isinstance(result.blocking_reasons, dict)
if result.should_offer_quest:
assert result.selected_quest_context is not None
context_dict = result.selected_quest_context.to_dict()
assert "quest_id" in context_dict
assert "offer_dialogue" in context_dict
class TestNPCApiQuestIntegration:
"""Tests verifying quest context flows through NPC API."""
def test_quest_offering_context_added_to_task_context(self):
"""Verify quest_offering_context field exists in task_context structure."""
# This is a structural test - verify the expected dict key exists
# in the code path (actual integration would require full app context)
# The task_context dict should include quest_offering_context
# Verified by reading npcs.py - this tests the pattern
expected_task_context_keys = [
"session_id",
"character_id",
"character",
"npc",
"npc_full",
"conversation_topic",
"game_state",
"npc_knowledge",
"revealed_secrets",
"interaction_count",
"relationship_level",
"previous_dialogue",
"quest_offering_context", # Our new field
]
# This serves as documentation that the field should exist
assert "quest_offering_context" in expected_task_context_keys
def test_generator_accepts_quest_offering_context(self):
"""Verify NarrativeGenerator accepts quest_offering_context parameter."""
from app.ai.narrative_generator import NarrativeGenerator
# Check the method signature accepts the parameter
import inspect
sig = inspect.signature(NarrativeGenerator.generate_npc_dialogue)
params = list(sig.parameters.keys())
assert "quest_offering_context" in params
class TestQuestOfferProbability:
"""Tests for probability-based quest offering."""
def test_probability_zero_never_offers(self):
"""With force_probability=0, quest should never be offered."""
char = MagicMock()
char.level = 5
char.active_quests = []
char.completed_quests = []
char.npc_interactions = {}
quest = Quest(
quest_id="quest_test",
name="Test",
description="Test",
difficulty=QuestDifficulty.EASY,
quest_giver_npc_ids=["npc_test"],
rewards=QuestReward(),
)
service = QuestEligibilityService()
with patch.object(service.quest_service, 'get_quests_for_npc', return_value=[quest]):
result = service.check_eligibility(
npc_id="npc_test",
character=char,
force_probability=0.0, # Never offer
)
assert not result.should_offer_quest
def test_probability_one_always_offers(self):
"""With force_probability=1, quest should always be offered."""
char = MagicMock()
char.level = 5
char.active_quests = []
char.completed_quests = []
char.npc_interactions = {"npc_test": {"relationship_level": 50, "custom_flags": {}}}
quest = Quest(
quest_id="quest_test",
name="Test",
description="Test",
difficulty=QuestDifficulty.EASY,
quest_giver_npc_ids=["npc_test"],
rewards=QuestReward(),
offering_triggers=QuestOfferingTriggers(
min_character_level=1,
max_character_level=10,
),
)
service = QuestEligibilityService()
with patch.object(service.quest_service, 'get_quests_for_npc', return_value=[quest]):
result = service.check_eligibility(
npc_id="npc_test",
character=char,
force_probability=1.0, # Always offer
)
assert result.should_offer_quest
assert result.selected_quest_context is not None
class TestQuestProgressUpdate:
"""Tests for quest progress update functionality."""
@pytest.fixture
def sample_quest_with_kill_objective(self):
"""Create a quest with a kill objective."""
return Quest(
quest_id="quest_kill_rats",
name="Rat Exterminator",
description="Kill the rats in the cellar",
difficulty=QuestDifficulty.EASY,
quest_giver_npc_ids=["npc_innkeeper"],
objectives=[
QuestObjective(
objective_id="kill_rats",
description="Kill 5 giant rats",
objective_type=ObjectiveType.KILL,
required_progress=5,
target_enemy_type="giant_rat",
)
],
rewards=QuestReward(gold=50, experience=100),
)
def test_objective_progress_increment(self):
"""Test that objective progress increments correctly."""
from app.models.quest import CharacterQuestState, QuestStatus
state = CharacterQuestState(
quest_id="quest_test",
status=QuestStatus.ACTIVE,
accepted_at="2025-11-29T10:00:00Z",
objectives_progress={"kill_rats": 0},
)
# Update progress
state.update_progress("kill_rats", 1)
assert state.get_progress("kill_rats") == 1
# Update again
state.update_progress("kill_rats", 2)
assert state.get_progress("kill_rats") == 3
def test_objective_completion_check(self, sample_quest_with_kill_objective):
"""Test that is_complete works correctly on objectives."""
obj = sample_quest_with_kill_objective.objectives[0]
# Not complete at 0
obj.current_progress = 0
assert not obj.is_complete
# Not complete at 4
obj.current_progress = 4
assert not obj.is_complete
# Complete at 5
obj.current_progress = 5
assert obj.is_complete
# Complete at more than required
obj.current_progress = 7
assert obj.is_complete
def test_quest_completion_requires_all_objectives(self):
"""Test that quest.is_complete requires all objectives done."""
quest = Quest(
quest_id="quest_multi",
name="Multi-Objective Quest",
description="Do multiple things",
difficulty=QuestDifficulty.MEDIUM,
quest_giver_npc_ids=["npc_test"],
objectives=[
QuestObjective(
objective_id="obj1",
description="Do first thing",
objective_type=ObjectiveType.KILL,
required_progress=3,
current_progress=3, # Complete
),
QuestObjective(
objective_id="obj2",
description="Do second thing",
objective_type=ObjectiveType.COLLECT,
required_progress=5,
current_progress=2, # Not complete
),
],
rewards=QuestReward(),
)
# Quest not complete because obj2 is incomplete
assert not quest.is_complete
# Complete obj2
quest.objectives[1].current_progress = 5
assert quest.is_complete
def test_progress_text_formatting(self):
"""Test objective.progress_text formatting."""
obj = QuestObjective(
objective_id="test",
description="Test",
objective_type=ObjectiveType.KILL,
required_progress=10,
current_progress=3,
)
assert obj.progress_text == "3/10"
obj.current_progress = 0
assert obj.progress_text == "0/10"
obj.current_progress = 10
assert obj.progress_text == "10/10"
class TestCombatKillTracking:
"""Tests for combat kill tracking integration."""
def test_kill_objective_matches_enemy_type(self):
"""Test that kill objectives match against target_enemy_type."""
obj = QuestObjective(
objective_id="kill_rats",
description="Kill 5 giant rats",
objective_type=ObjectiveType.KILL,
required_progress=5,
target_enemy_type="giant_rat",
)
# Should match
assert obj.target_enemy_type == "giant_rat"
assert obj.objective_type == ObjectiveType.KILL
# Test non-kill objective doesn't have target
obj2 = QuestObjective(
objective_id="collect_items",
description="Collect 3 rat tails",
objective_type=ObjectiveType.COLLECT,
required_progress=3,
target_item_id="rat_tail",
)
assert obj2.objective_type == ObjectiveType.COLLECT
assert obj2.target_enemy_type is None
class TestQuestRewardsSerialization:
"""Tests for quest rewards serialization."""
def test_rewards_to_dict(self):
"""Test QuestReward.to_dict() contains all fields."""
rewards = QuestReward(
gold=100,
experience=250,
items=["health_potion", "sword_of_rats"],
relationship_bonuses={"npc_innkeeper": 10},
unlocks_quests=["quest_deeper_tunnels"],
reveals_locations=["secret_cellar"],
)
data = rewards.to_dict()
assert data["gold"] == 100
assert data["experience"] == 250
assert "health_potion" in data["items"]
assert "sword_of_rats" in data["items"]
assert data["relationship_bonuses"]["npc_innkeeper"] == 10
assert "quest_deeper_tunnels" in data["unlocks_quests"]
assert "secret_cellar" in data["reveals_locations"]
def test_rewards_from_dict(self):
"""Test QuestReward.from_dict() reconstructs correctly."""
data = {
"gold": 50,
"experience": 100,
"items": ["item1"],
"relationship_bonuses": {"npc1": 5},
"unlocks_quests": [],
"reveals_locations": [],
}
rewards = QuestReward.from_dict(data)
assert rewards.gold == 50
assert rewards.experience == 100
assert rewards.items == ["item1"]
assert rewards.relationship_bonuses == {"npc1": 5}
class TestQuestStateSerialization:
"""Tests for CharacterQuestState serialization."""
def test_quest_state_to_dict(self):
"""Test CharacterQuestState.to_dict() serialization."""
from app.models.quest import CharacterQuestState, QuestStatus
state = CharacterQuestState(
quest_id="quest_test",
status=QuestStatus.ACTIVE,
accepted_at="2025-11-29T10:00:00Z",
objectives_progress={"obj1": 3, "obj2": 0},
completed_at=None,
)
data = state.to_dict()
assert data["quest_id"] == "quest_test"
assert data["status"] == "active"
assert data["accepted_at"] == "2025-11-29T10:00:00Z"
assert data["objectives_progress"]["obj1"] == 3
assert data["objectives_progress"]["obj2"] == 0
assert data["completed_at"] is None
def test_quest_state_from_dict(self):
"""Test CharacterQuestState.from_dict() deserialization."""
from app.models.quest import CharacterQuestState, QuestStatus
data = {
"quest_id": "quest_rats",
"status": "active",
"accepted_at": "2025-11-29T12:00:00Z",
"objectives_progress": {"kill_rats": 5},
"completed_at": None,
}
state = CharacterQuestState.from_dict(data)
assert state.quest_id == "quest_rats"
assert state.status == QuestStatus.ACTIVE
assert state.accepted_at == "2025-11-29T12:00:00Z"
assert state.get_progress("kill_rats") == 5
assert state.completed_at is None
def test_quest_state_completed(self):
"""Test CharacterQuestState with completed status."""
from app.models.quest import CharacterQuestState, QuestStatus
data = {
"quest_id": "quest_done",
"status": "completed",
"accepted_at": "2025-11-29T10:00:00Z",
"objectives_progress": {"obj1": 10},
"completed_at": "2025-11-29T11:00:00Z",
}
state = CharacterQuestState.from_dict(data)
assert state.status == QuestStatus.COMPLETED
assert state.completed_at == "2025-11-29T11:00:00Z"

View 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

View File

@@ -1,865 +0,0 @@
# Phase 4: Combat & Progression Systems - Implementation Plan
**Status:** In Progress - Week 2 Complete, Week 3 Next
**Timeline:** 4-5 weeks
**Last Updated:** November 27, 2025
**Document Version:** 1.3
---
## Completion Summary
### Week 1: Combat Backend - COMPLETE
| Task | Description | Status | Tests |
|------|-------------|--------|-------|
| 1.1 | Verify Combat Data Models | ✅ Complete | - |
| 1.2 | Implement Combat Service | ✅ Complete | 25 tests |
| 1.3 | Implement Damage Calculator | ✅ Complete | 39 tests |
| 1.4 | Implement Effect Processor | ✅ Complete | - |
| 1.5 | Implement Combat Actions | ✅ Complete | - |
| 1.6 | Combat API Endpoints | ✅ Complete | 19 tests |
| 1.7 | Manual API Testing | ⏭️ Skipped | - |
**Files Created:**
- `/api/app/models/enemy.py` - EnemyTemplate, LootEntry dataclasses
- `/api/app/services/enemy_loader.py` - YAML-based enemy loading
- `/api/app/services/combat_service.py` - Combat orchestration service
- `/api/app/services/damage_calculator.py` - Damage formula calculations
- `/api/app/api/combat.py` - REST API endpoints
- `/api/app/data/enemies/*.yaml` - 6 sample enemy definitions
- `/api/tests/test_damage_calculator.py` - 39 tests
- `/api/tests/test_enemy_loader.py` - 25 tests
- `/api/tests/test_combat_service.py` - 25 tests
- `/api/tests/test_combat_api.py` - 19 tests
**Total Tests:** 108 passing
### Week 2: Inventory & Equipment - COMPLETE
| Task | Description | Status | Tests |
|------|-------------|--------|-------|
| 2.1 | Item Data Models (Affixes) | ✅ Complete | 24 tests |
| 2.2 | Item Data Files (YAML) | ✅ Complete | - |
| 2.2.1 | Item Generator Service | ✅ Complete | 35 tests |
| 2.3 | Inventory Service | ✅ Complete | 24 tests |
| 2.4 | Inventory API Endpoints | ✅ Complete | 25 tests |
| 2.5 | Character Stats Calculation | ✅ Complete | 17 tests |
| 2.6 | Equipment-Combat Integration | ✅ Complete | 140 tests |
| 2.7 | Combat Loot Integration | ✅ Complete | 59 tests |
**Files Created/Modified:**
- `/api/app/models/items.py` - Item with affix support, spell_power field
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
- `/api/app/models/stats.py` - spell_power_bonus, updated damage formula
- `/api/app/models/combat.py` - Combatant weapon properties
- `/api/app/services/item_generator.py` - Procedural item generation
- `/api/app/services/inventory_service.py` - Equipment management
- `/api/app/services/damage_calculator.py` - Refactored to use stats properties
- `/api/app/services/combat_service.py` - Equipment integration
- `/api/app/api/inventory.py` - REST API endpoints
**Total Tests (Week 2):** 324+ passing
---
## Overview
This phase implements the core combat and progression systems for Code of Conquest, enabling turn-based tactical combat, inventory management, equipment, skill trees, and the NPC shop. This is a prerequisite for the story progression and quest systems.
**Key Deliverables:**
- Turn-based combat system (API + UI)
- Inventory & equipment management
- Skill tree visualization and unlocking
- XP and leveling system
- NPC shop
---
## Phase Structure
| Sub-Phase | Duration | Focus |
|-----------|----------|-------|
| **Phase 4A** | 2-3 weeks | Combat Foundation |
| **Phase 4B** | 1-2 weeks | Skill Trees & Leveling | See [`/PHASE4b.md`](/PHASE4b.md)
| **Phase 4C** | 3-4 days | NPC Shop | [`/PHASE4c.md`](/PHASE4c.md)
**Total Estimated Time:** 4-5 weeks (~140-175 hours)
---
## Phase 4A: Combat Foundation (Weeks 1-3)
### Week 1: Combat Backend & Data Models ✅ COMPLETE
#### Task 1.1: Verify Combat Data Models ✅ COMPLETE
**Files:** `/api/app/models/combat.py`, `effects.py`, `abilities.py`, `stats.py`
Verified: Combatant, CombatEncounter dataclasses, effect types (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD), stacking logic, YAML ability loading, serialization methods.
---
#### Task 1.2: Implement Combat Service ✅ COMPLETE
**File:** `/api/app/services/combat_service.py`
Implemented: `CombatService` class with `initiate_combat()`, `process_action()`, initiative rolling, turn management, death checking, combat end detection.
---
#### Task 1.3: Implement Damage Calculator ✅ COMPLETE
**File:** `/api/app/services/damage_calculator.py`
Implemented: `calculate_physical_damage()`, `calculate_magical_damage()`, `apply_damage()` with shield absorption. Physical formula: `weapon.damage + (STR/2) - defense`. 39 unit tests.
---
#### Task 1.4: Implement Effect Processor ✅ COMPLETE
**File:** `/api/app/models/effects.py`
Implemented: `tick()` method for DOT/HOT damage/healing, duration tracking, stat modifiers via `get_effective_stats()`.
---
#### Task 1.5: Implement Combat Actions ✅ COMPLETE
**File:** `/api/app/services/combat_service.py`
Implemented: `_execute_attack()`, `_execute_spell()`, `_execute_item()`, `_execute_defend()` with mana costs, cooldowns, effect application.
---
#### Task 1.6: Combat API Endpoints ✅ COMPLETE
**File:** `/api/app/api/combat.py`
**Endpoints:**
- `POST /api/v1/combat/start` - Initiate combat
- `POST /api/v1/combat/<combat_id>/action` - Take action
- `GET /api/v1/combat/<combat_id>/state` - Get state
- `POST /api/v1/combat/<combat_id>/flee` - Attempt flee
- `POST /api/v1/combat/<combat_id>/enemy-turn` - Enemy AI
- `GET /api/v1/combat/enemies` - List templates (public)
- `GET /api/v1/combat/enemies/<id>` - Enemy details (public)
19 integration tests passing.
---
#### Task 1.7: Manual API Testing ⏭️ SKIPPED
Covered by 108 comprehensive automated tests.
---
### Week 2: Inventory & Equipment System ✅ COMPLETE
#### Task 2.1: Item Data Models ✅ COMPLETE
**Files:** `/api/app/models/items.py`, `affixes.py`, `enums.py`
Implemented: `Item` dataclass with affix support (`applied_affixes`, `base_template_id`, `generated_name`, `is_generated`), `Affix` model (PREFIX/SUFFIX types, MINOR/MAJOR/LEGENDARY tiers), `BaseItemTemplate` for procedural generation. 24 tests.
---
#### Task 2.2: Item Data Files ✅ COMPLETE
**Directory:** `/api/app/data/`
Created:
- `base_items/weapons.yaml` - 13 weapon templates
- `base_items/armor.yaml` - 12 armor templates (cloth/leather/chain/plate)
- `affixes/prefixes.yaml` - 18 prefixes (elemental, material, quality, legendary)
- `affixes/suffixes.yaml` - 11 suffixes (stat bonuses, animal totems, legendary)
- `items/consumables/potions.yaml` - Health/mana potions (small/medium/large)
---
#### Task 2.2.1: Item Generator Service ✅ COMPLETE
**Files:** `/api/app/services/item_generator.py`, `affix_loader.py`, `base_item_loader.py`
Implemented Diablo-style procedural generation:
- Affix distribution: COMMON/UNCOMMON (0), RARE (1), EPIC (2), LEGENDARY (3)
- Name generation: "Flaming Dagger of Strength"
- Tier weights by rarity (RARE: 80% MINOR, EPIC: 70% MAJOR, LEGENDARY: 50% LEGENDARY)
- Luck-influenced rarity rolling
35 tests.
---
#### Task 2.3: Implement Inventory Service ✅ COMPLETE
**File:** `/api/app/services/inventory_service.py`
Implemented: `add_item()`, `remove_item()`, `equip_item()`, `unequip_item()`, `use_consumable()`, `use_consumable_in_combat()`. Full object storage for generated items. Validation for slots, levels, item types. 24 tests.
---
#### Task 2.4: Inventory API Endpoints ✅ COMPLETE
**File:** `/api/app/api/inventory.py`
**Endpoints:**
- `GET /api/v1/characters/<id>/inventory` - Get inventory + equipped
- `POST /api/v1/characters/<id>/inventory/equip` - Equip item
- `POST /api/v1/characters/<id>/inventory/unequip` - Unequip item
- `POST /api/v1/characters/<id>/inventory/use` - Use consumable
- `DELETE /api/v1/characters/<id>/inventory/<item_id>` - Drop item
25 tests.
---
#### Task 2.5: Update Character Stats Calculation ✅ COMPLETE
**Files:** `/api/app/models/stats.py`, `character.py`
Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields to Stats. Updated `get_effective_stats()` to populate from equipped weapon/armor. 17 tests.
---
#### Task 2.6: Equipment-Combat Integration ✅ COMPLETE
**Files:** `stats.py`, `items.py`, `character.py`, `combat.py`, `combat_service.py`, `damage_calculator.py`
Key changes:
- Damage scaling: `int(STR * 0.75) + damage_bonus` (was `STR // 2`)
- Added `spell_power` system for magical weapons
- Combatant weapon properties (crit_chance, crit_multiplier, elemental support)
- DamageCalculator uses `stats.damage` directly (removed `weapon_damage` param)
140 tests.
---
#### Task 2.7: Combat Loot Integration ✅ COMPLETE
**Files:** `combat_loot_service.py`, `static_item_loader.py`, `app/models/enemy.py`
Implemented hybrid loot system:
- Static drops (consumables, materials) via `StaticItemLoader`
- Procedural drops (equipment) via `ItemGenerator`
- Difficulty bonuses: EASY +0%, MEDIUM +5%, HARD +15%, BOSS +30%
- Enemy variants: goblin_scout, goblin_warrior, goblin_chieftain
59 tests.
---
### Week 3: Combat UI
#### Task 3.1: Create Combat Template ✅ COMPLETE
**Objective:** Build HTMX-powered combat interface
**File:** `/public_web/templates/game/combat.html`
**Layout:**
```
┌─────────────────────────────────────────────────────────────┐
│ COMBAT ENCOUNTER │
├───────────────┬─────────────────────────┬───────────────────┤
│ │ │ │
│ YOUR │ COMBAT LOG │ TURN ORDER │
│ CHARACTER │ │ ─────────── │
│ ───────── │ Goblin attacks you │ 1. Aragorn ✓ │
│ HP: ████ 80 │ for 12 damage! │ 2. Goblin │
│ MP: ███ 60 │ │ 3. Orc │
│ │ You attack Goblin │ │
│ ENEMY │ for 18 damage! │ ACTIVE EFFECTS │
│ ───────── │ CRITICAL HIT! │ ─────────── │
│ Goblin │ │ 🛡️ Defending │
│ HP: ██ 12 │ Goblin is stunned! │ (1 turn) │
│ │ │ │
│ │ ───────────────── │ │
│ │ ACTION BUTTONS │ │
│ │ ───────────────── │ │
│ │ [Attack] [Spell] │ │
│ │ [Item] [Defend] │ │
│ │ │ │
└───────────────┴─────────────────────────┴───────────────────┘
```
**Implementation:**
```html
{% extends "base.html" %}
{% block title %}Combat - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-container">
<h1 class="combat-title">⚔️ COMBAT ENCOUNTER</h1>
<div class="combat-grid">
{# Left Panel - Combatants #}
<aside class="combat-panel combat-combatants">
<div class="combatant-card player-card">
<h3>{{ character.name }}</h3>
<div class="hp-bar">
<div class="hp-fill" style="width: {{ (character.current_hp / character.stats.max_hp * 100)|int }}%"></div>
<span class="hp-text">HP: {{ character.current_hp }} / {{ character.stats.max_hp }}</span>
</div>
<div class="mp-bar">
<div class="mp-fill" style="width: {{ (character.current_mp / character.stats.max_mp * 100)|int }}%"></div>
<span class="mp-text">MP: {{ character.current_mp }} / {{ character.stats.max_mp }}</span>
</div>
</div>
<div class="vs-divider">VS</div>
{% for enemy in enemies %}
<div class="combatant-card enemy-card" id="enemy-{{ loop.index0 }}">
<h3>{{ enemy.name }}</h3>
<div class="hp-bar">
<div class="hp-fill enemy" style="width: {{ (enemy.current_hp / enemy.stats.max_hp * 100)|int }}%"></div>
<span class="hp-text">HP: {{ enemy.current_hp }} / {{ enemy.stats.max_hp }}</span>
</div>
{% if enemy.current_hp > 0 %}
<button class="btn btn-target" onclick="selectTarget('{{ enemy.combatant_id }}')">
Target
</button>
{% else %}
<span class="defeated-badge">DEFEATED</span>
{% endif %}
</div>
{% endfor %}
</aside>
{# Middle Panel - Combat Log & Actions #}
<section class="combat-panel combat-main">
<div class="combat-log" id="combat-log">
<h3>Combat Log</h3>
<div class="log-entries">
{% for entry in combat_log[-10:] %}
<div class="log-entry">{{ entry }}</div>
{% endfor %}
</div>
</div>
<div class="combat-actions" id="combat-actions">
<h3>Your Turn</h3>
<div class="action-buttons">
<button class="btn btn-action btn-attack"
hx-post="/combat/{{ combat_id }}/action"
hx-vals='{"action_type": "attack", "ability_id": "basic_attack", "target_id": ""}'
hx-target="#combat-container"
hx-swap="outerHTML">
⚔️ Attack
</button>
<button class="btn btn-action btn-spell"
onclick="openSpellMenu()">
✨ Cast Spell
</button>
<button class="btn btn-action btn-item"
onclick="openItemMenu()">
🎒 Use Item
</button>
<button class="btn btn-action btn-defend"
hx-post="/combat/{{ combat_id }}/action"
hx-vals='{"action_type": "defend"}'
hx-target="#combat-container"
hx-swap="outerHTML">
🛡️ Defend
</button>
</div>
</div>
</section>
{# Right Panel - Turn Order & Effects #}
<aside class="combat-panel combat-sidebar">
<div class="turn-order">
<h3>Turn Order</h3>
<ol>
{% for combatant_id in turn_order %}
<li class="{% if loop.index0 == current_turn_index %}active-turn{% endif %}">
{{ get_combatant_name(combatant_id) }}
{% if loop.index0 == current_turn_index %}✓{% endif %}
</li>
{% endfor %}
</ol>
</div>
<div class="active-effects">
<h3>Active Effects</h3>
{% for effect in character.active_effects %}
<div class="effect-badge {{ effect.effect_type }}">
{{ effect.name }} ({{ effect.duration }})
</div>
{% endfor %}
</div>
</aside>
</div>
</div>
{# Modal Container #}
<div id="modal-container"></div>
{% endblock %}
{% block scripts %}
<script>
let selectedTargetId = null;
function selectTarget(targetId) {
selectedTargetId = targetId;
// Update UI to show selected target
document.querySelectorAll('.btn-target').forEach(btn => {
btn.classList.remove('selected');
});
event.target.classList.add('selected');
}
function openSpellMenu() {
// TODO: Open modal with spell selection
}
function openItemMenu() {
// TODO: Open modal with item selection
}
// Auto-scroll combat log to bottom
const logDiv = document.querySelector('.log-entries');
if (logDiv) {
logDiv.scrollTop = logDiv.scrollHeight;
}
</script>
{% endblock %}
```
**Also create `/public_web/static/css/combat.css`**
**Acceptance Criteria:**
- 3-column layout works
- Combat log displays messages
- HP/MP bars update dynamically
- Action buttons trigger HTMX requests
- Turn order displays correctly
- Active effects shown
---
#### Task 3.2: Combat HTMX Integration ✅ COMPLETE
**Objective:** Wire combat UI to API via HTMX
**File:** `/public_web/app/views/game_views.py`
**Implementation:**
```python
"""
Combat Views
Routes for combat UI.
"""
from flask import Blueprint, render_template, request, g, redirect, url_for
from app.services.api_client import APIClient, APIError
from app.utils.auth import require_auth
from app.utils.logging import get_logger
logger = get_logger(__file__)
combat_bp = Blueprint('combat', __name__)
@combat_bp.route('/<combat_id>')
@require_auth
def combat_view(combat_id: str):
"""Display combat interface."""
api_client = APIClient()
try:
# Get combat state
response = api_client.get(f'/combat/{combat_id}/state')
combat_state = response['result']
return render_template(
'game/combat.html',
combat_id=combat_id,
combat_state=combat_state,
turn_order=combat_state['turn_order'],
current_turn_index=combat_state['current_turn_index'],
combat_log=combat_state['combat_log'],
character=combat_state['combatants'][0], # Player is first
enemies=combat_state['combatants'][1:] # Rest are enemies
)
except APIError as e:
logger.error(f"Failed to load combat {combat_id}: {e}")
return redirect(url_for('game.play'))
@combat_bp.route('/<combat_id>/action', methods=['POST'])
@require_auth
def combat_action(combat_id: str):
"""Process combat action (HTMX endpoint)."""
api_client = APIClient()
action_data = {
'action_type': request.form.get('action_type'),
'ability_id': request.form.get('ability_id'),
'target_id': request.form.get('target_id'),
'item_id': request.form.get('item_id')
}
try:
# Submit action to API
response = api_client.post(f'/combat/{combat_id}/action', json=action_data)
result = response['result']
# Check if combat ended
if result['combat_state']['status'] in ['victory', 'defeat']:
return redirect(url_for('combat.combat_results', combat_id=combat_id))
# Re-render combat view with updated state
return render_template(
'game/combat.html',
combat_id=combat_id,
combat_state=result['combat_state'],
turn_order=result['combat_state']['turn_order'],
current_turn_index=result['combat_state']['current_turn_index'],
combat_log=result['combat_state']['combat_log'],
character=result['combat_state']['combatants'][0],
enemies=result['combat_state']['combatants'][1:]
)
except APIError as e:
logger.error(f"Combat action failed: {e}")
return render_template('partials/error.html', error=str(e))
@combat_bp.route('/<combat_id>/results')
@require_auth
def combat_results(combat_id: str):
"""Display combat results (victory/defeat)."""
api_client = APIClient()
try:
response = api_client.get(f'/combat/{combat_id}/results')
results = response['result']
return render_template(
'game/combat_results.html',
victory=results['victory'],
xp_gained=results['xp_gained'],
gold_gained=results['gold_gained'],
loot=results['loot']
)
except APIError as e:
logger.error(f"Failed to load combat results: {e}")
return redirect(url_for('game.play'))
```
**Register blueprint in `/public_web/app/__init__.py`:**
```python
from app.views.combat import combat_bp
app.register_blueprint(combat_bp, url_prefix='/combat')
```
**Acceptance Criteria:**
- Combat view loads from API
- Action buttons submit to API
- Combat state updates dynamically
- Combat results shown at end
- Errors handled gracefully
---
#### Task 3.3: Inventory UI ✅ COMPLETE
**Objective:** Add inventory accordion to character panel
**File:** `/public_web/templates/game/partials/character_panel.html`
**Add Inventory Section:**
```html
{# Existing character panel code #}
{# Add Inventory Accordion #}
<div class="panel-accordion" data-accordion="inventory">
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
<span>Inventory <span class="count">({{ character.inventory|length }}/{{ inventory_max }})</span></span>
<span class="accordion-icon"></span>
</button>
<div class="panel-accordion-content">
<div class="inventory-grid">
{% for item in inventory %}
<div class="inventory-item {{ item.rarity }}"
hx-get="/inventory/{{ character.character_id }}/item/{{ item.item_id }}"
hx-target="#modal-container"
hx-swap="innerHTML">
<img src="{{ item.icon_url or '/static/img/items/default.png' }}" alt="{{ item.name }}">
<span class="item-name">{{ item.name }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{# Equipment Section #}
<div class="panel-accordion" data-accordion="equipment">
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
<span>Equipment</span>
<span class="accordion-icon"></span>
</button>
<div class="panel-accordion-content">
<div class="equipment-slots">
<div class="equipment-slot">
<label>Weapon:</label>
{% if character.equipped.weapon %}
<span class="equipped-item">{{ get_item_name(character.equipped.weapon) }}</span>
<button class="btn-small"
hx-post="/inventory/{{ character.character_id }}/unequip"
hx-vals='{"slot": "weapon"}'
hx-target="#character-panel"
hx-swap="outerHTML">
Unequip
</button>
{% else %}
<span class="empty-slot">Empty</span>
{% endif %}
</div>
<div class="equipment-slot">
<label>Helmet:</label>
{# Similar for helmet, chest, boots, etc. #}
</div>
</div>
</div>
</div>
```
**Create `/public_web/templates/game/partials/item_modal.html`:**
```html
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 class="item-name {{ item.rarity }}">{{ item.name }}</h2>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<p class="item-description">{{ item.description }}</p>
<div class="item-stats">
{% if item.item_type == 'weapon' %}
<p><strong>Damage:</strong> {{ item.damage }}</p>
<p><strong>Crit Chance:</strong> {{ (item.crit_chance * 100)|int }}%</p>
{% elif item.item_type == 'armor' %}
<p><strong>Defense:</strong> {{ item.defense }}</p>
<p><strong>Resistance:</strong> {{ item.resistance }}</p>
{% elif item.item_type == 'consumable' %}
<p><strong>HP Restore:</strong> {{ item.hp_restore }}</p>
<p><strong>MP Restore:</strong> {{ item.mp_restore }}</p>
{% endif %}
</div>
<p class="item-value">Value: {{ item.value }} gold</p>
</div>
<div class="modal-footer">
{% if item.item_type == 'weapon' %}
<button class="btn btn-primary"
hx-post="/inventory/{{ character_id }}/equip"
hx-vals='{"item_id": "{{ item.item_id }}", "slot": "weapon"}'
hx-target="#character-panel"
hx-swap="outerHTML">
Equip Weapon
</button>
{% elif item.item_type == 'consumable' %}
<button class="btn btn-primary"
hx-post="/inventory/{{ character_id }}/use"
hx-vals='{"item_id": "{{ item.item_id }}"}'
hx-target="#character-panel"
hx-swap="outerHTML">
Use Item
</button>
{% endif %}
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</div>
</div>
```
**Acceptance Criteria:**
- Inventory displays in character panel
- Click item shows modal with details
- Equip/unequip works via HTMX
- Use consumable works
- Equipment slots show equipped items
---
#### Task 3.4: Combat Testing & Polish ✅ COMPLETE
**Objective:** Playtest combat and fix bugs
**Testing Checklist:**
- ✅ Start combat from story session
- ✅ Turn order correct
- ✅ Attack deals damage
- ✅ Critical hits work
- ✅ Effects apply and tick correctly
- ✅ Defend action works
- ✅ Victory awards XP/gold/loot
- ✅ Defeat handling works
- ✅ Combat log readable
- ✅ HP/MP bars update
- ✅ Multiple enemies work - would like to update to allow the player to select which enemy to attack
- ✅ Combat state persists (refresh page)
- [ ] Spells consume mana - Need to test
- [ ] Items can be used in combat - Need to test
**Bug Fixes & Polish:**
- Fix any calculation errors
- Improve combat log messages
- Add visual feedback (animations, highlights)
- Improve mobile responsiveness
- Add loading states
**Acceptance Criteria:**
- Combat flows smoothly start to finish
- No critical bugs
- UX feels responsive and clear
- Ready for real gameplay
---
## Phase 4B: Skill Trees & Leveling (Week 4)
See [`/PHASE4b.md`](/PHASE4b.md)
## Phase 4C: NPC Shop (Days 15-18)
See [`/PHASE4c.md`](/PHASE4c.md)
## Success Criteria - Phase 4 Complete
### Combat System
- [ ] Turn-based combat works end-to-end
- [ ] Damage calculations correct (physical, magical, critical)
- [ ] Effects process correctly (DOT, HOT, buffs, debuffs, shields, stun)
- [ ] Combat UI functional and responsive
- [ ] Victory awards XP, gold, loot
- [ ] Combat state persists
### Inventory System
- [ ] Inventory displays in UI
- [ ] Equip/unequip items works
- [ ] Consumables can be used
- [ ] Equipment affects character stats
- [ ] Item YAML data loaded correctly
### Skill Trees
- [ ] Visual skill tree UI works
- [ ] Prerequisites enforced
- [ ] Unlock skills with skill points
- [ ] Respec functionality works
- [ ] Stat bonuses apply immediately
### Leveling
- [ ] XP awarded after combat
- [ ] Level up triggers at threshold
- [ ] Skill points granted on level up
- [ ] Level up modal shown
- [ ] Character stats increase
### NPC Shop
- [ ] Shop inventory displays
- [ ] Purchase validation works
- [ ] Items added to inventory
- [ ] Gold deducted correctly
- [ ] Transactions logged
---
## Next Steps After Phase 4
Once Phase 4 is complete, you'll have a fully playable combat game with progression. The next logical phases are:
**Phase 5: Story Progression & Quests** (Original Phase 4 from roadmap)
- AI-driven story progression
- Action prompts (button-based gameplay)
- Quest system (YAML-driven, context-aware)
- Full gameplay loop: Explore → Combat → Quests → Level Up
**Phase 6: Multiplayer Sessions**
- Invite-based co-op
- Time-limited sessions
- AI-generated campaigns
**Phase 7: Marketplace & Economy**
- Player-to-player trading
- Auction system
- Economy balancing
---
## Appendix: Testing Strategy
### Manual Testing Checklist
**Combat:**
- [ ] Start combat from story
- [ ] Turn order correct
- [ ] Attack deals damage
- [ ] Spells work
- [ ] Items usable in combat
- [ ] Defend action
- [ ] Victory conditions
- [ ] Defeat handling
**Inventory:**
- [ ] Add items
- [ ] Remove items
- [ ] Equip weapons
- [ ] Equip armor
- [ ] Use consumables
- [ ] Inventory UI updates
**Skills:**
- [ ] View skill trees
- [ ] Unlock skills
- [ ] Prerequisites enforced
- [ ] Stat bonuses apply
- [ ] Respec works
**Shop:**
- [ ] Browse inventory
- [ ] Purchase items
- [ ] Insufficient gold handling
- [ ] Transaction logging
---
## Document Maintenance
**Update this document as you complete tasks:**
- Mark tasks complete with ✅
- Add notes about implementation decisions
- Update time estimates based on actual progress
- Document any blockers or challenges
**Good luck with Phase 4 implementation!** 🚀

View File

@@ -1,272 +0,0 @@
# Production Play Screen Implementation Plan
## Overview
Create a new production play screen at `templates/game/play.html` with a 3-column layout optimized for immersive gameplay, separate from the dev console.
## Layout Structure
```
+-------------+------------------------+------------------+
| LEFT | MIDDLE | RIGHT |
| (280px) | (1fr flex) | (320px) |
+-------------+------------------------+------------------+
| Character | Location Header | [History] |
| - Name/Lv | - Name, Type, Turn # | [Quests] |
| - HP/MP | Ambient Details | [NPCs] |
| - Stats | ---------------- | [Map] |
| ---------- | DM Response Area | |
| Actions | (main narrative) | Each accordion |
| [Free] | | independently |
| [Premium] | | refreshable |
| [Elite] | | |
| ---------- | | |
| [Talk NPC] | | |
| [Travel] | | |
+-------------+------------------------+------------------+
```
## Files to Create
### Templates
| File | Purpose |
|------|---------|
| `templates/game/play.html` | Main 3-column layout |
| `templates/game/partials/character_panel.html` | Left: actions + character stats |
| `templates/game/partials/narrative_panel.html` | Middle: DM response + location |
| `templates/game/partials/sidebar_history.html` | Right accordion: turn history |
| `templates/game/partials/sidebar_quests.html` | Right accordion: active quests |
| `templates/game/partials/sidebar_npcs.html` | Right accordion: NPCs at location |
| `templates/game/partials/sidebar_map.html` | Right accordion: discovered locations |
| `templates/game/partials/job_polling.html` | Job status polling partial |
| `templates/game/partials/travel_modal.html` | Travel destination modal |
| `templates/game/partials/npc_chat_modal.html` | NPC dialogue modal |
### CSS
| File | Purpose |
|------|---------|
| `static/css/play.css` | All play screen styles |
### Flask Views
| File | Purpose |
|------|---------|
| `app/views/game_views.py` | New blueprint for production game routes |
### Modify
| File | Change |
|------|--------|
| `app/__init__.py` | Register `game_bp` blueprint |
## Flask Routes
```python
# Main routes
GET /play/session/<session_id> # Main play screen
GET /play/session/<id>/character-panel # Refresh character stats
GET /play/session/<id>/narrative # Refresh narrative
GET /play/session/<id>/history # Refresh history accordion
GET /play/session/<id>/quests # Refresh quests accordion
GET /play/session/<id>/npcs # Refresh NPCs accordion
GET /play/session/<id>/map # Refresh map accordion
# Action routes
POST /play/session/<id>/action # Submit action -> job polling
GET /play/session/<id>/job/<job_id> # Poll job status
# Modal routes
GET /play/session/<id>/travel-modal # Get travel modal
POST /play/session/<id>/travel # Execute travel
GET /play/session/<id>/npc/<npc_id>/chat # Get NPC chat modal
POST /play/session/<id>/npc/<npc_id>/talk # Send message to NPC
```
## CSS Theme
```css
/* Dark fantasy theme matching existing */
--play-bg-primary: #1a1a2a;
--play-bg-secondary: #2a2a3a;
--play-border: #4a4a5a;
/* Action tiers */
--action-free: #3b82f6; /* Blue */
--action-premium: #8b5cf6; /* Purple */
--action-elite: #f59e0b; /* Gold */
/* Resource bars */
--hp-bar-fill: #ef4444;
--mp-bar-fill: #3b82f6;
```
## Action Button Organization
**Free Tier (Blue):**
1. Ask Locals for Information (town/tavern)
2. Explore the Area (wilderness/dungeon)
3. Search for Supplies (any) - 2 turn cooldown
4. Rest and Recover (town/tavern/safe) - 3 turn cooldown
**Premium Tier (Purple):**
5. Investigate Suspicious Activity (any)
6. Follow a Lead (any)
7. Make Camp (wilderness) - 5 turn cooldown
**Elite Tier (Gold):**
8. Consult Ancient Texts (library/town) - 3 turn cooldown
9. Commune with Nature (wilderness) - 4 turn cooldown
10. Seek Audience with Authorities (town) - 5 turn cooldown
## HTMX Patterns
### Action Submission
```html
<button hx-post="/play/session/{id}/action"
hx-vals='{"action_type":"button","prompt_id":"ask_locals"}'
hx-target="#narrative-content"
hx-indicator="#loading"
hx-disabled-elt="this">
```
### Job Polling (1s interval)
```html
<div hx-get="/play/session/{id}/job/{job_id}"
hx-trigger="load delay:1s"
hx-swap="innerHTML">
```
### Cascade Refresh (after action completes)
```html
<!-- Hidden triggers in dm_response.html -->
<div hx-get="/play/session/{id}/history" hx-target="#accordion-history" hx-trigger="load" hidden></div>
<div hx-get="/play/session/{id}/npcs" hx-target="#accordion-npcs" hx-trigger="load" hidden></div>
```
## Responsive Design
- **Desktop (>1024px):** 3-column grid
- **Tablet (768-1024px):** 2-column, left sidebar as slide-out drawer
- **Mobile (<768px):** Single column, right sidebar as bottom sheet
## Implementation Approach: Visual First
Build complete UI with mock/sample data first, then wire up real API calls. This allows rapid iteration on layout and styling before integrating backend.
### Phase 1: Complete Visual Layout with Mock Data
1. Create `game_views.py` with main route using hardcoded mock data
2. Create `play.html` base template with 3-column grid
3. Create `play.css` with all styles (theme, grid, components)
4. Register blueprint
5. Build all visual components with sample content:
- Left: Character panel with mock stats/actions
- Middle: Narrative panel with sample DM text
- Right: All 4 accordions with mock entries
### Phase 2: Interactive Components (No API)
1. Accordion toggle functionality (JavaScript)
2. Modal open/close (travel + NPC chat)
3. Action button states (hover, disabled, loading simulation)
4. Collapsible ambient details
5. Responsive breakpoints
### Phase 3: Wire Up API Integration ✅ COMPLETE (Nov 24, 2025)
1. ✅ Replace mock data with real API calls in Flask view
2. ✅ Implement job polling for actions
3. ✅ Wire up travel modal to `/api/v1/travel`
4. ✅ Wire up NPC chat to `/api/v1/npcs/{id}/talk`
5. ✅ Add HTMX refresh triggers for accordions
6. ✅ Wire up equipment modal with real character data
### Phase 4: Polish
- Error handling states
- Loading indicators
- Empty state messages
- Accessibility (ARIA labels, keyboard nav)
## Critical Reference Files
1. `public_web/templates/dev/story_session.html` - HTMX patterns, job polling
2. `public_web/app/views/dev.py` - Flask view patterns, API client usage
3. `public_web/static/css/main.css` - Theme variables, base styles
4. `api/app/data/action_prompts.yaml` - All 10 action definitions
5. `api/app/api/sessions.py` - Session API response formats
## API Endpoints Used
All endpoints already exist:
- `GET /api/v1/sessions/{id}` - Session state (updated to include `character_id`)
- `POST /api/v1/sessions/{id}/action` - Submit action
- `GET /api/v1/sessions/{id}/history` - Conversation history
- `GET /api/v1/jobs/{id}/status` - Poll job status
- `GET /api/v1/characters/{id}` - Character data
- `GET /api/v1/npcs/at-location/{id}` - NPCs at location
- `POST /api/v1/npcs/{id}/talk` - Talk to NPC
- `GET /api/v1/travel/available` - Available destinations
- `POST /api/v1/travel` - Travel to location
---
## Implementation Notes (Phase 3)
### Session Creation from Characters Page
Added the ability for players to create game sessions directly from `/characters`:
**Files Modified:**
- `public_web/app/views/character_views.py`
- Updated `list_characters()` to fetch user sessions via `GET /api/v1/sessions` and map them to characters
- Added new route `POST /characters/<character_id>/play` (`create_session`) that creates a session and redirects to play screen
- `public_web/templates/character/list.html`
- Added sessions section showing active sessions per character (up to 3 displayed)
- Added "Continue Playing" button (resumes latest session)
- Added "New Session" button (creates new session)
- Added "Start Adventure" button for characters without sessions
- Added CSS for session badges (turn number, status indicator)
### Play Screen API Integration
**Files Modified:**
- `public_web/app/views/game_views.py`
- Removed all mock data constants
- Added helper functions:
- `_get_user_tier(client)` - Gets user subscription tier
- `_build_location_from_game_state(game_state)` - Builds location dict from session
- `_build_character_from_api(char_data)` - Transforms API character response with robust defaults
- Updated all routes to use real API calls:
- `play_session` - Main play screen
- `character_panel` - Character stats refresh
- `narrative_panel` - Narrative content refresh
- `history_accordion`, `quests_accordion`, `npcs_accordion`, `map_accordion` - Sidebar refreshes
- `take_action` - Submit actions to API, return job polling partial
- `poll_job` - Poll job status, handle NPC dialogue vs story responses
- `equipment_modal`, `travel_modal`, `npc_chat_modal` - Modal data loading
- `do_travel`, `talk_to_npc` - Execute travel and NPC dialogue
**New Template:**
- `public_web/templates/game/partials/npc_dialogue_response.html` - Displays NPC dialogue from job polling results
### API Changes
**Files Modified:**
- `api/app/api/sessions.py`
- Updated `get_session_state()` to include `character_id` and `status` in response
- This was required because the play screen needs to know which character to load
### CSS Fixes
**Files Modified:**
- `public_web/static/css/play.css`
- Increased `max-width` from 1800px to 2400px for wider screens
- Changed middle column from `1fr` to `minmax(500px, 1fr)` to ensure minimum width
- Fixed NPC tags overflow:
- Added `flex-wrap: wrap` to `.npc-tags`
- Added `overflow: hidden` and `max-height: 3.5em`
- Added `white-space: nowrap` to `.npc-tag`
### Bug Fixes
1. **Character data showing "Unknown"** - Fixed `_build_character_from_api()` to always return complete dict with sensible defaults even when API data is empty or incomplete
2. **API not returning character_id** - Updated `api/app/api/sessions.py` to include `character_id` in session state response
3. **NPC tags overflow** - Fixed CSS to wrap tags and hide overflow

View File

@@ -1,513 +0,0 @@
## Phase 4C: NPC Shop (Days 15-18)
### Task 5.1: Define Shop Inventory (4 hours)
**Objective:** Create YAML for shop items
**File:** `/api/app/data/shop/general_store.yaml`
```yaml
shop_id: "general_store"
shop_name: "General Store"
shop_description: "A well-stocked general store with essential supplies."
shopkeeper_name: "Merchant Guildmaster"
inventory:
# Weapons
- item_id: "iron_sword"
stock: -1 # Unlimited stock (-1)
price: 50
- item_id: "oak_bow"
stock: -1
price: 45
# Armor
- item_id: "leather_helmet"
stock: -1
price: 30
- item_id: "leather_chest"
stock: -1
price: 60
# Consumables
- item_id: "health_potion_small"
stock: -1
price: 10
- item_id: "health_potion_medium"
stock: -1
price: 30
- item_id: "mana_potion_small"
stock: -1
price: 15
- item_id: "antidote"
stock: -1
price: 20
```
**Acceptance Criteria:**
- Shop inventory defined in YAML
- Mix of weapons, armor, consumables
- Reasonable pricing
- Unlimited stock for basics
---
### Task 5.2: Shop API Endpoints (4 hours)
**Objective:** Create shop endpoints
**File:** `/api/app/api/shop.py`
```python
"""
Shop API Blueprint
Endpoints:
- GET /api/v1/shop/inventory - Browse shop items
- POST /api/v1/shop/purchase - Purchase item
"""
from flask import Blueprint, request, g
from app.services.shop_service import ShopService
from app.services.character_service import get_character_service
from app.services.appwrite_service import get_appwrite_service
from app.utils.response import success_response, error_response
from app.utils.auth import require_auth
from app.utils.logging import get_logger
logger = get_logger(__file__)
shop_bp = Blueprint('shop', __name__)
@shop_bp.route('/inventory', methods=['GET'])
@require_auth
def get_shop_inventory():
"""Get shop inventory."""
shop_service = ShopService()
inventory = shop_service.get_shop_inventory("general_store")
return success_response({
'shop_name': "General Store",
'inventory': [
{
'item': item.to_dict(),
'price': price,
'in_stock': True
}
for item, price in inventory
]
})
@shop_bp.route('/purchase', methods=['POST'])
@require_auth
def purchase_item():
"""
Purchase item from shop.
Request JSON:
{
"character_id": "char_abc",
"item_id": "iron_sword",
"quantity": 1
}
"""
data = request.get_json()
character_id = data.get('character_id')
item_id = data.get('item_id')
quantity = data.get('quantity', 1)
# Get character
char_service = get_character_service()
character = char_service.get_character(character_id, g.user_id)
# Purchase item
shop_service = ShopService()
try:
result = shop_service.purchase_item(
character,
"general_store",
item_id,
quantity
)
# Save character
char_service.update_character(character)
return success_response(result)
except Exception as e:
return error_response(str(e), 400)
```
**Also create `/api/app/services/shop_service.py`:**
```python
"""
Shop Service
Manages NPC shop inventory and purchases.
"""
import yaml
from typing import List, Tuple
from app.models.items import Item
from app.models.character import Character
from app.services.item_loader import ItemLoader
from app.utils.logging import get_logger
logger = get_logger(__file__)
class ShopService:
"""Service for NPC shops."""
def __init__(self):
self.item_loader = ItemLoader()
self.shops = self._load_shops()
def _load_shops(self) -> dict:
"""Load all shop data from YAML."""
shops = {}
with open('app/data/shop/general_store.yaml', 'r') as f:
shop_data = yaml.safe_load(f)
shops[shop_data['shop_id']] = shop_data
return shops
def get_shop_inventory(self, shop_id: str) -> List[Tuple[Item, int]]:
"""
Get shop inventory.
Returns:
List of (Item, price) tuples
"""
shop = self.shops.get(shop_id)
if not shop:
return []
inventory = []
for item_data in shop['inventory']:
item = self.item_loader.get_item(item_data['item_id'])
price = item_data['price']
inventory.append((item, price))
return inventory
def purchase_item(
self,
character: Character,
shop_id: str,
item_id: str,
quantity: int = 1
) -> dict:
"""
Purchase item from shop.
Args:
character: Character instance
shop_id: Shop ID
item_id: Item to purchase
quantity: Quantity to buy
Returns:
Purchase result dict
Raises:
ValueError: If insufficient gold or item not found
"""
shop = self.shops.get(shop_id)
if not shop:
raise ValueError("Shop not found")
# Find item in shop inventory
item_data = next(
(i for i in shop['inventory'] if i['item_id'] == item_id),
None
)
if not item_data:
raise ValueError("Item not available in shop")
price = item_data['price'] * quantity
# Check if character has enough gold
if character.gold < price:
raise ValueError(f"Not enough gold. Need {price}, have {character.gold}")
# Deduct gold
character.gold -= price
# Add items to inventory
for _ in range(quantity):
if item_id not in character.inventory_item_ids:
character.inventory_item_ids.append(item_id)
else:
# Item already exists, increment stack (if stackable)
# For now, just add multiple entries
character.inventory_item_ids.append(item_id)
logger.info(f"Character {character.character_id} purchased {quantity}x {item_id} for {price} gold")
return {
'item_purchased': item_id,
'quantity': quantity,
'total_cost': price,
'gold_remaining': character.gold
}
```
**Acceptance Criteria:**
- Shop inventory endpoint works
- Purchase endpoint validates gold
- Items added to inventory
- Gold deducted
- Transactions logged
---
### Task 5.3: Shop UI (1 day / 8 hours)
**Objective:** Shop browse and purchase interface
**File:** `/public_web/templates/shop/index.html`
```html
{% extends "base.html" %}
{% block title %}Shop - Code of Conquest{% endblock %}
{% block content %}
<div class="shop-container">
<div class="shop-header">
<h1>🏪 {{ shop_name }}</h1>
<p class="shopkeeper">Shopkeeper: {{ shopkeeper_name }}</p>
<p class="player-gold">Your Gold: <strong>{{ character.gold }}</strong></p>
</div>
<div class="shop-inventory">
{% for item_entry in inventory %}
<div class="shop-item-card {{ item_entry.item.rarity }}">
<div class="item-header">
<h3>{{ item_entry.item.name }}</h3>
<span class="item-price">{{ item_entry.price }} gold</span>
</div>
<p class="item-description">{{ item_entry.item.description }}</p>
<div class="item-stats">
{% if item_entry.item.item_type == 'weapon' %}
<span>⚔️ Damage: {{ item_entry.item.damage }}</span>
{% elif item_entry.item.item_type == 'armor' %}
<span>🛡️ Defense: {{ item_entry.item.defense }}</span>
{% elif item_entry.item.item_type == 'consumable' %}
<span>❤️ Restores: {{ item_entry.item.hp_restore }} HP</span>
{% endif %}
</div>
<button class="btn btn-primary btn-purchase"
{% if character.gold < item_entry.price %}disabled{% endif %}
hx-post="/shop/purchase"
hx-vals='{"character_id": "{{ character.character_id }}", "item_id": "{{ item_entry.item.item_id }}"}'
hx-target=".shop-container"
hx-swap="outerHTML">
{% if character.gold >= item_entry.price %}
Purchase
{% else %}
Not Enough Gold
{% endif %}
</button>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
```
**Create view in `/public_web/app/views/shop.py`:**
```python
"""
Shop Views
"""
from flask import Blueprint, render_template, request, g
from app.services.api_client import APIClient, APIError
from app.utils.auth import require_auth
from app.utils.logging import get_logger
logger = get_logger(__file__)
shop_bp = Blueprint('shop', __name__)
@shop_bp.route('/')
@require_auth
def shop_index():
"""Display shop."""
api_client = APIClient()
try:
# Get shop inventory
shop_response = api_client.get('/shop/inventory')
inventory = shop_response['result']['inventory']
# Get character (for gold display)
char_response = api_client.get(f'/characters/{g.character_id}')
character = char_response['result']
return render_template(
'shop/index.html',
shop_name="General Store",
shopkeeper_name="Merchant Guildmaster",
inventory=inventory,
character=character
)
except APIError as e:
logger.error(f"Failed to load shop: {e}")
return render_template('partials/error.html', error=str(e))
@shop_bp.route('/purchase', methods=['POST'])
@require_auth
def purchase():
"""Purchase item (HTMX endpoint)."""
api_client = APIClient()
purchase_data = {
'character_id': request.form.get('character_id'),
'item_id': request.form.get('item_id'),
'quantity': 1
}
try:
response = api_client.post('/shop/purchase', json=purchase_data)
# Reload shop
return shop_index()
except APIError as e:
logger.error(f"Purchase failed: {e}")
return render_template('partials/error.html', error=str(e))
```
**Acceptance Criteria:**
- Shop displays all items
- Item cards show stats and price
- Purchase button disabled if not enough gold
- Purchase adds item to inventory
- Gold updates dynamically
- UI refreshes after purchase
---
### Task 5.4: Transaction Logging (2 hours)
**Objective:** Log all shop purchases
**File:** `/api/app/models/transaction.py`
```python
"""
Transaction Model
Tracks all gold transactions (shop, trades, etc.)
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any
@dataclass
class Transaction:
"""Represents a gold transaction."""
transaction_id: str
transaction_type: str # "shop_purchase", "trade", "quest_reward", etc.
character_id: str
amount: int # Negative for expenses, positive for income
description: str
timestamp: datetime = field(default_factory=datetime.utcnow)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dict."""
return {
"transaction_id": self.transaction_id,
"transaction_type": self.transaction_type,
"character_id": self.character_id,
"amount": self.amount,
"description": self.description,
"timestamp": self.timestamp.isoformat(),
"metadata": self.metadata
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
"""Deserialize from dict."""
return cls(
transaction_id=data["transaction_id"],
transaction_type=data["transaction_type"],
character_id=data["character_id"],
amount=data["amount"],
description=data["description"],
timestamp=datetime.fromisoformat(data["timestamp"]),
metadata=data.get("metadata", {})
)
```
**Update `ShopService.purchase_item()` to log transaction:**
```python
# In shop_service.py
def purchase_item(...):
# ... existing code ...
# Log transaction
from app.models.transaction import Transaction
import uuid
transaction = Transaction(
transaction_id=str(uuid.uuid4()),
transaction_type="shop_purchase",
character_id=character.character_id,
amount=-price,
description=f"Purchased {quantity}x {item_id} from {shop_id}",
metadata={
"shop_id": shop_id,
"item_id": item_id,
"quantity": quantity,
"unit_price": item_data['price']
}
)
# Save to database
from app.services.appwrite_service import get_appwrite_service
appwrite = get_appwrite_service()
appwrite.create_document("transactions", transaction.transaction_id, transaction.to_dict())
# ... rest of code ...
```
**Acceptance Criteria:**
- All purchases logged to database
- Transaction records complete
- Can query transaction history
---

View File

@@ -1,531 +1,163 @@
# Code of Conquest - Implementation Roadmap # Code of Conquest - Development Roadmap
**Project:** Code of Conquest (AI Dungeon Master)
**Last Updated:** November 15, 2025
**Status:** Active Development **Status:** Active Development
**Last Updated:** November 29, 2025
--- ---
## Development Philosophy ## Overview
**Workflow:** Brainstorm → Design → Code → Revise Code of Conquest is a web-based AI-powered Dungeons & Dragons style game where Claude acts as the Dungeon Master. This roadmap tracks development progress from MVP to public launch.
**Principles:** **Tech Stack:** Flask + Jinja2 + HTMX + Appwrite + RQ + Redis + Anthropic/Replicate APIs
- Build incrementally (MVP first, then expand)
- Test each phase before moving forward
- Security and cost control from day one
- Manual testing preferred for most features
- Focus on core gameplay before polish
--- ---
## Phase 0: Foundation (Week 1-2) ✅ COMPLETE ## Phase Summary
**Goal:** Set up development environment and project structure | Phase | Name | Priority | Status | Tasks |
|-------|------|----------|--------|-------|
| 1-4 | Foundation & Core Systems | - | ✅ Complete | - |
| 5 | [Quest System](phases/Phase5-Quests.md) | High | ⬜ Not Started | 14 |
| 6 | [Story Progression & Lore](phases/Phase6-StoryProgression.md) | High | ⬜ Not Started | 22 |
| 7 | [Multiplayer Sessions](phases/Phase7-Multiplayer.md) | Low | ⬜ Not Started | 25 |
| 8 | [Marketplace](phases/Phase8-Marketplace.md) | Medium | ⬜ Not Started | 17 |
| 9 | [Frontend Polish](phases/Phase9-FrontendPolish.md) | Medium | ⬜ Not Started | 19 |
| 10 | [PWA & Deployment](phases/Phase10-Deployment.md) | High | ⬜ Not Started | 19 |
| 11 | [Beta Testing](phases/Phase11-BetaTesting.md) | High | ⬜ Not Started | 17 |
| 12 | [Launch Preparation](phases/Phase12-Launch.md) | High | ⬜ Not Started | 19 |
### Tasks **Total Remaining Tasks:** 152
| Task | Priority | Status | Notes |
|------|----------|--------|-------|
| Initialize git repository | High | ✅ | Set up dev/beta/master branches |
| Create project structure | High | ✅ | `/app`, `/config`, `/tests`, `/static`, `/templates` |
| Set up virtual environment | High | ✅ | `python3 -m venv venv` |
| Create requirements.txt | High | ✅ | Pin version ranges for all dependencies |
| Configure .env and .env.example | High | ✅ | API keys, secrets, config |
| Set up Docker Compose (local) | High | ✅ | Redis container |
| Configure Appwrite project | High | ✅ | Create project, get credentials |
| Set up Appwrite collections | High | ✅ | users, characters, game_sessions, marketplace_listings, transactions |
| Configure logging (structlog) | Medium | ✅ | Centralized logging setup |
| Create config loader | Medium | ✅ | YAML config + typed dataclass |
| Write API response wrapper | Medium | ✅ | Standardized JSON response format |
| Set up Flask app factory | High | ✅ | `app/__init__.py` |
**Deliverable:** Fully configured development environment, ready for coding
--- ---
## Phase 1: Core Data Models (Week 3) ✅ COMPLETE ## Completed Phases (1-4)
**Goal:** Implement all core dataclasses and serialization ### Phase 1: Foundation
- ✅ Project setup (Flask, Appwrite, Redis)
- ✅ Authentication system
- ✅ User management
### Tasks ### Phase 2: Character System
- ✅ Character creation with origins and classes
- ✅ Attribute system (STR, DEX, CON, INT, WIS, CHA)
- ✅ Skill trees with unlock mechanics
- ✅ Experience and leveling
| Task | Priority | Status | Notes | ### Phase 3: Items & Inventory
|------|----------|--------|-------| - ✅ Item system with affixes and rarity
| Implement Stats dataclass | High | ✅ | Include computed properties (HP, MP) | - ✅ Equipment and inventory management
| Implement Item dataclass | High | ✅ | All item types | - ✅ NPC shop system
| Implement Effect dataclass | High | ✅ | BUFF, DEBUFF, DOT, HOT, STUN, SHIELD |
| Implement SkillNode dataclass | High | ✅ | With prerequisite checking |
| Implement SkillTree dataclass | High | ✅ | With can_unlock() method |
| Implement PlayerClass dataclass | High | ✅ | Base stats + skill trees |
| Implement Character dataclass | High | ✅ | to_json(), from_json(), get_effective_stats() |
| Implement Combatant dataclass | High | ✅ | For combat encounters |
| Implement CombatEncounter dataclass | High | ✅ | Combat state management |
| Implement SessionConfig dataclass | High | ✅ | Session settings |
| Implement GameSession dataclass | High | ✅ | Full session state |
| Implement MarketplaceListing dataclass | Medium | ✅ | Auction + fixed price |
| Write unit tests for dataclasses | High | ✅ | 68 tests passing, >80% coverage |
**Deliverable:** ✅ Complete data model layer with tests - All 68 tests passing! ### Phase 4: AI Integration & Core Gameplay
- ✅ AI Dungeon Master integration
### Phase 1 Implementation Summary - ✅ Turn-based combat system
- ✅ NPC dialogue system with personality and knowledge
**What Was Built:** - ✅ Location and travel system
- **10 data model files** in `/app/models/`: - ✅ Session management
- `enums.py` - 9 enum types for type safety - ✅ Action prompt system
- `stats.py` - Stats with computed properties (HP, MP, defense, resistance)
- `effects.py` - 6 effect types (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD) with stacking
- `abilities.py` - Ability system with YAML loader (data-driven design)
- `items.py` - Items (weapons, armor, consumables, quest items)
- `skills.py` - SkillNode, SkillTree, PlayerClass
- `character.py` - Character with **get_effective_stats()** (single source of truth)
- `combat.py` - Combatant, CombatEncounter with turn-based flow
- `session.py` - GameSession, SessionConfig, GameState, ConversationEntry
- `marketplace.py` - MarketplaceListing, Bid, Transaction, ShopItem
**Test Coverage:**
- 68 tests across 4 test files, all passing
- `test_stats.py` - 12 tests for Stats dataclass
- `test_effects.py` - 17 tests for all effect types
- `test_character.py` - 20 tests including get_effective_stats()
- `test_combat_simulation.py` - 19 tests including full combat flow
**Key Design Decisions Made:**
| Decision | Rationale |
|----------|-----------|
| **Abilities from YAML** | Data-driven design for easy balancing without code changes |
| **Effect stacking capped at max_stacks** | Prevents infinite stacking abuse (default 5, configurable) |
| **Duration refreshes on re-application** | Simpler than cumulative duration, more predictable |
| **Shield damage absorption** | Fully implemented with partial/complete absorption logic |
| **Critical hits only** | No damage variance = deterministic damage except crits (JRPG-style) |
| **Stat minimum clamped at 1** | Debuffs can't reduce stats below 1 (prevents zero/negative stats) |
| **Single source of truth** | Character.get_effective_stats() combines all modifiers (base + equip + skills + effects) |
**Combat System Features:**
- Deterministic damage calculation with stat scaling
- 6 effect types with tick() processing
- Shield absorption before HP damage
- Effect stacking with configurable caps
- Turn-based initiative system
- Mana costs and ability cooldowns
- Full combat simulation tested
**Data Serialization:**
- All models have to_dict() / from_dict() methods
- Enums serialize to .value strings
- JSON-compatible for Appwrite storage
- Round-trip serialization tested
**Documentation Updated:**
- DATA_MODELS.md - Added Ability, AbilityLoader, Enums, expanded Effect/Character
- GAME_SYSTEMS.md - Added Ability System section, clarified effect stacking, shields, crits
- ARCHITECTURE.md - Updated models directory structure
--- ---
## Phase 2: Authentication & User Management (Week 4) ✅ COMPLETE ## Active Development
**Goal:** Implement user authentication via Appwrite ### Phase 5: Quest System
**Goal:** YAML-driven quest system with context-aware offering
### Tasks [View Full Phase Document →](phases/Phase5-Quests.md)
| Task | Priority | Status | Notes | **Key Deliverables:**
|------|----------|--------|-------| - Quest data models (Quest, Objective, Reward, Triggers)
| Set up Appwrite service wrapper | High | ✅ | `app/services/appwrite_service.py` | - 10+ quests in YAML format
| Implement login endpoint | High | ✅ | `POST /api/v1/auth/login` | - Context-aware quest offering during gameplay
| Implement register endpoint | High | ✅ | `POST /api/v1/auth/register` | - Quest acceptance, tracking, and completion
| Implement logout endpoint | High | ✅ | `POST /api/v1/auth/logout` | - Quest tracker UI
| Implement email verification | High | ✅ | `GET /api/v1/auth/verify-email` |
| Implement password reset | High | ✅ | `POST /api/v1/auth/forgot-password`, `POST /api/v1/auth/reset-password` |
| Create JWT token middleware | High | ✅ | Verify tokens on protected routes |
| Implement user tier/subscription check | High | ✅ | Free/Basic/Premium/Elite |
| Create auth decorators | High | ✅ | @require_auth, @require_tier(), @require_email_verified |
| Write auth tests | Medium | ✅ | Manual API testing with docs/API_TESTING.md |
| Create login page template | Medium | ✅ | `templates/auth/login.html` with HTMX |
| Create register page template | Medium | ✅ | `templates/auth/register.html` with password strength |
| Create password reset templates | Medium | ✅ | `templates/auth/forgot_password.html`, `reset_password.html` |
| Create base template | Medium | ✅ | `templates/base.html` with RPG/fantasy theme |
| Create main stylesheet | Medium | ✅ | `static/css/main.css` with approved dark slate theme |
**Deliverable:** ✅ Complete authentication system with email verification, password reset, and RPG-themed UI
--- ---
## Phase 3: Character System (Week 5-6) ✅ COMPLETE ### Phase 6: Story Progression & Lore
**Goal:** Vector database-powered NPC knowledge and world lore
**Goal:** Character creation, management, and skill system [View Full Phase Document →](phases/Phase6-StoryProgression.md)
### Tasks **Key Deliverables:**
- Weaviate vector database integration
| Task | Priority | Status | Notes | - World lore content (history, mythology, kingdoms)
|------|----------|--------|-------| - Regional lore for starter region
| Define all 8 player classes | High | ✅ | Using reference data from data files | - NPC knowledge integration via RAG
| Design skill trees (2 per class) | High | ✅ | Loaded from YAML data files | - Knowledge filtering by NPC role/profession
| Implement character CRUD API | High | ✅ | 7 endpoints: create, get, list, delete, unlock skill, respec |
| Implement classes/origins API | High | ✅ | 3 endpoints: list classes, get class, list origins |
| Implement skill unlock endpoint | High | ✅ | `POST /api/v1/characters/<id>/skills/unlock` |
| Implement skill respec endpoint | Medium | ✅ | `POST /api/v1/characters/<id>/skills/respec` |
| Implement effective stats calculation | High | ✅ | In Character dataclass from Phase 1 |
| Write character system tests | Medium | ✅ | 18 integration tests in test_api_characters_integration.py |
| Create API testing doc | Medium | ✅ | `docs/API_TESTING.md` with character endpoints |
| Create character creation UI | High | ✅ | 4-step flow: origin → class → customize → confirm |
| Create character list UI | High | ✅ | Show all user's characters with tier limits |
| Create character detail UI | High | ✅ | Stats, inventory, equipment display |
**Deliverable:** ✅ Full character creation and management system (API + UI complete)
**Progress:** 12/12 tasks complete (100%)
**Note:** Skill tree visualization UI moved to Phase 5 (Combat System) where it will be implemented alongside combat abilities.
--- ---
## Phase 4: AI Integration + Story Progression System (Week 7-9) 🎯 NEXT PHASE ## Upcoming Phases
**Goal:** Integrate AI narrative generation and implement turn-based story progression with quest system ### Phase 7: Multiplayer Sessions (Low Priority)
**Goal:** Invite-based, time-limited co-op sessions
**Total Tasks:** 45 tasks across 3 weeks (~126 hours) [View Full Phase Document →](phases/Phase7-Multiplayer.md)
**Implementation Details:** See [api/docs/PHASE4_IMPLEMENTATION.md](../api/docs/PHASE4_IMPLEMENTATION.md) for granular task breakdowns, code examples, and verification steps.
### Overview
Phase 4 delivers the core single-player gameplay experience where players interact with the AI Dungeon Master through button-based actions. This phase includes AI infrastructure, story progression, and the quest system.
### Week 7: AI Engine Foundation (15 tasks)
**Task Groups:**
- **Group 1: Redis & RQ Infrastructure** (4 tasks)
- **Group 2: AI API Clients** (4 tasks)
- **Group 3: Prompt Templates & Narrative Generation** (4 tasks)
- **Group 4: Usage Tracking & Cost Controls** (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.1 | Set up Redis service wrapper | ⬜ | Connection pooling, TTL support |
| 7.2 | Configure RQ job queues | ⬜ | ai_tasks, combat_tasks, marketplace_tasks |
| 7.3 | Create base AI task job structure | ⬜ | Status tracking, retry logic |
| 7.4 | ✅ Checkpoint: Verify Redis/RQ | ⬜ | Integration test |
| 7.5 | Implement Replicate API client | ⬜ | Llama-3 8B for free tier |
| 7.6 | Implement Anthropic API client | ⬜ | Haiku/Sonnet/Opus support |
| 7.7 | Implement model selector | ⬜ | Tier-based routing |
| 7.8 | ✅ Checkpoint: Verify all models | ⬜ | Test each AI model |
| 7.9 | Create Jinja2 prompt templates | ⬜ | 4 core templates |
| 7.10 | Implement narrative generator | ⬜ | High-level generation API |
| 7.11 | Build AI task jobs | ⬜ | Async processing with Appwrite |
| 7.12 | ✅ Checkpoint: E2E AI flow | ⬜ | Full job lifecycle test |
| 7.13 | Implement AI usage tracking | ⬜ | Log tokens, costs to Appwrite |
| 7.14 | Implement daily limit checks | ⬜ | Per-tier rate limiting |
| 7.15 | Set up cost monitoring & alerts | ⬜ | Daily cost reports, email alerts |
**Deliverable:** Working AI narrative generation with tier-based models and cost controls
### Week 8: Story Progression System (16 tasks)
**Task Groups:**
- **Group 5: Action Prompts & Data Layer** (4 tasks)
- **Group 6: Session Management** (5 tasks)
- **Group 7: Story API Endpoints** (4 tasks)
- **Group 8: Story UI & Integration** (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 8.16 | Create ActionPrompt dataclass | ⬜ | Tier/context filtering |
| 8.17 | Create 10 action prompts in YAML | ⬜ | Free(4), Premium(+3), Elite(+3) |
| 8.18 | Implement ActionPromptLoader | ⬜ | YAML loading and filtering |
| 8.19 | ✅ Checkpoint: Verify action filtering | ⬜ | Test tier/context logic |
| 8.20 | Extend GameSession for solo play | ⬜ | Add state tracking fields |
| 8.21 | Implement SessionService | ⬜ | Create/load/update sessions |
| 8.22 | Add conversation history management | ⬜ | History CRUD methods |
| 8.23 | Add game state tracking | ⬜ | Location, quests, events |
| 8.24 | ✅ Checkpoint: Verify persistence | ⬜ | Test Appwrite storage |
| 8.25 | Implement create session endpoint | ⬜ | POST /api/v1/sessions |
| 8.26 | Implement take action endpoint | ⬜ | POST /sessions/{id}/action (async) |
| 8.27 | Implement get session state endpoint | ⬜ | GET /api/v1/sessions/{id} |
| 8.28 | Implement get history endpoint | ⬜ | GET /sessions/{id}/history |
| 8.29 | Create story gameplay template | ⬜ | HTMX-powered UI |
| 8.30 | Build action button UI | ⬜ | Tier filtering, custom input |
| 8.31 | ✅ Checkpoint: Full integration test | ⬜ | Complete story turn flow |
**Deliverable:** Complete turn-based story progression with button-based actions
### Week 9: Quest System (14 tasks)
**Task Groups:**
- **Group 9: Quest Data Models** (3 tasks)
- **Group 10: Quest Content & Loading** (4 tasks)
- **Group 11: Quest Offering & Management** (4 tasks)
- **Group 12: Quest UI & Final Testing** (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.32 | Create Quest dataclasses | ⬜ | Quest, Objective, Reward, Triggers |
| 9.33 | Create QuestTriggers with offering logic | ⬜ | Location/level-based eligibility |
| 9.34 | ✅ Checkpoint: Verify serialization | ⬜ | Test round-trip to JSON |
| 9.35 | Create quest YAML schema | ⬜ | Document in QUEST_SYSTEM.md |
| 9.36 | Write 10 example quests | ⬜ | 4 easy, 3 medium, 2 hard, 1 epic |
| 9.37 | Implement QuestService | ⬜ | YAML loading, filtering |
| 9.38 | ✅ Checkpoint: Verify quest loading | ⬜ | Test all 10 quests load |
| 9.39 | Implement context-aware offering | ⬜ | Probability + AI selection |
| 9.40 | Integrate offering into story turns | ⬜ | Check after each action |
| 9.41 | Implement quest accept endpoint | ⬜ | POST /api/v1/quests/accept |
| 9.42 | Implement quest complete endpoint | ⬜ | Rewards, level up check |
| 9.43 | Create quest tracker sidebar UI | ⬜ | Active quests display |
| 9.44 | Create quest offering modal UI | ⬜ | Accept/decline interface |
| 9.45 | ✅ Final Checkpoint: Full integration | ⬜ | Complete quest lifecycle |
**Deliverable:** YAML-driven quest system with context-aware offering
### Phase 4 Success Criteria
- [ ] AI clients for Anthropic and Replicate functional
- [ ] Tier-based model selection working (Free→Replicate, Premium→Sonnet, etc.)
- [ ] Cost tracking and daily limits enforced
- [ ] 10 action prompts defined and loadable from YAML
- [ ] Solo session creation and management working
- [ ] Story turn flow functional (action → AI response → state update)
- [ ] Conversation history persisted and displayed
- [ ] Tier restrictions enforced (Free: 4 buttons, Premium: 7 + free-form, Elite: 10 + free-form)
- [ ] 10 example quests defined in YAML
- [ ] Quest offering logic working (context-aware + location-based)
- [ ] Quest tracking and completion functional
- [ ] Max 2 active quests enforced
**Estimated Timeline:** ~126 hours (~16 days of focused work)
---
## Phase 5: Combat System + Skill Tree UI (Week 10-11)
**Goal:** Turn-based combat with AI narration + skill tree visualization
**Note:** Combat reuses the turn-based infrastructure built in Phase 4 (story progression). The main additions are damage calculation, effect processing, combat-specific UI, and the skill tree visualization deferred from Phase 3.
### Tasks
| Task | Priority | Status | Notes |
|------|----------|--------|-------|
| Implement damage calculator | High | ⬜ | Physical, magical, critical |
| Implement effect processor | High | ⬜ | Tick effects, apply damage |
| Implement combat engine | High | ⬜ | Turn order, action processing |
| Implement initiative system | High | ⬜ | d20 + speed rolls |
| Implement combat API endpoints | High | ⬜ | Attack, cast, item, defend |
| Implement combat state management | High | ⬜ | Start, process, end combat |
| Implement AI combat narration | High | ⬜ | Generate descriptions for actions |
| Create loot generator | Medium | ⬜ | Random loot by tier |
| Implement XP/leveling | Medium | ⬜ | Award XP, level up characters |
| Create combat UI | High | ⬜ | `templates/game/combat.html` |
| Create combat action UI | High | ⬜ | Buttons for attack/spell/item |
| Create skill tree UI | High | ⬜ | `templates/character/skills.html` - Dual tree display (from Phase 3) |
| Implement skill node visualization | High | ⬜ | Show 5 tiers × 2 nodes per tree, locked/available/unlocked states |
| Implement skill unlock UI | High | ⬜ | Click to unlock with HTMX, prerequisite validation |
| Implement respec UI | Medium | ⬜ | Respec button with confirmation modal (costs gold) |
| Write combat tests | High | ⬜ | Test damage, effects, flow |
| Write skill tree UI tests | Medium | ⬜ | Test unlock flow, respec, validation |
**Deliverable:** Fully functional combat system + interactive skill tree UI
**Total Tasks:** 17 tasks (12 combat + 5 skill tree UI)
---
## Phase 6: Multiplayer Sessions (Week 12-13)
**Goal:** Invite-based, time-limited co-op sessions for Premium/Elite players
**Note:** Multiplayer is a paid-tier feature focused on short co-op adventures. Unlike solo story progression, multiplayer sessions are time-limited (2 hours), invite-based, and combat-focused.
**Key Features:**
- Premium/Elite tier only - Premium/Elite tier only
- Shareable invite links
- 2-4 player parties - 2-4 player parties
- 2-hour session duration - AI-generated campaigns
- AI-generated custom campaigns
- Realtime synchronization - Realtime synchronization
- Character snapshots (doesn't affect solo campaigns)
**See:** [MULTIPLAYER.md](MULTIPLAYER.md) for complete specification
### Week 12: Core Multiplayer Infrastructure (Days 1-7)
| Task | Priority | Status | Notes |
|------|----------|--------|-------|
| Create MultiplayerSession dataclass | High | ⬜ | Extends GameSession with time limits, invite codes |
| Create PartyMember dataclass | High | ⬜ | Player info, character snapshot |
| Create MultiplayerCampaign models | High | ⬜ | Campaign, CampaignEncounter, CampaignRewards |
| Implement invite code generation | High | ⬜ | 8-char alphanumeric, unique, 24hr expiration |
| Implement session creation API | High | ⬜ | `POST /api/v1/sessions/multiplayer/create` (Premium/Elite only) |
| Implement join via invite API | High | ⬜ | `GET/POST /api/v1/sessions/multiplayer/join/{invite_code}` |
| Implement lobby system | High | ⬜ | Ready status, player list, host controls |
| Implement 2-hour timer logic | High | ⬜ | Session expiration, warnings (10min, 5min, 1min), auto-end |
| Set up Appwrite Realtime | High | ⬜ | WebSocket subscriptions for live session updates |
| Write unit tests | Medium | ⬜ | Invite generation, join validation, timer logic |
### Week 13: Campaign Generation & Combat (Days 8-14)
| Task | Priority | Status | Notes |
|------|----------|--------|-------|
| Implement AI campaign generator | High | ⬜ | Generate 3-5 encounters based on party composition |
| Create campaign templates | Medium | ⬜ | Pre-built campaign structures for AI to fill |
| Implement turn management | High | ⬜ | Initiative, turn order, action validation for multiplayer |
| Implement multiplayer combat flow | High | ⬜ | Reuse Phase 5 combat system, add multi-player support |
| Implement disconnect handling | High | ⬜ | Auto-defend mode, host promotion on disconnect |
| Implement reward distribution | High | ⬜ | Calculate and grant rewards at session end |
| Create lobby UI | High | ⬜ | `templates/multiplayer/lobby.html` - Player list, ready status, invite link |
| Create active session UI | High | ⬜ | `templates/multiplayer/session.html` - Timer, party status, combat, narrative |
| Create session complete UI | High | ⬜ | `templates/multiplayer/complete.html` - Rewards, stats, MVP badges |
| Write integration tests | High | ⬜ | Full session flow: create → join → play → complete |
| Test realtime synchronization | High | ⬜ | Multiple browsers simulating party gameplay |
| Test session expiration | Medium | ⬜ | Force expiration, verify cleanup and reward distribution |
**Deliverable:** Working invite-based multiplayer system with time-limited co-op campaigns
**Total Tasks:** 22 tasks across 2 weeks
--- ---
## Phase 7: NPC Shop (Week 12) ### Phase 8: Marketplace
**Goal:** Player-to-player trading system
**Goal:** Basic economy and item purchasing [View Full Phase Document →](phases/Phase8-Marketplace.md)
### Tasks - Auction and fixed-price listings
- Bidding with notifications
| Task | Priority | Status | Notes | - 5% marketplace fee
|------|----------|--------|-------|
| Define shop inventory | High | ⬜ | Items, prices, categories |
| Implement shop browse API | High | ⬜ | `GET /api/v1/shop/items` |
| Implement shop purchase API | High | ⬜ | `POST /api/v1/shop/purchase` |
| Implement transaction logging | Medium | ⬜ | Record all purchases |
| Create shop UI | High | ⬜ | `templates/shop/index.html` |
| Test shop purchases | Medium | ⬜ | Verify gold deduction, item add |
**Deliverable:** Working NPC shop system
--- ---
## Phase 8: Marketplace (Week 13-14) ### Phase 9: Frontend Polish
**Goal:** UI/UX improvements and design system
**Goal:** Player-to-player trading (Premium+ only) [View Full Phase Document →](phases/Phase9-FrontendPolish.md)
### Tasks - Dark fantasy aesthetic
- Mobile-responsive design
| Task | Priority | Status | Notes | - HTMX enhancements
|------|----------|--------|-------| - Reusable components
| Implement marketplace browse API | High | ⬜ | Filtering, sorting, pagination |
| Implement listing creation API | High | ⬜ | Auction + fixed price |
| Implement bidding API | High | ⬜ | Validate bid amounts |
| Implement buyout API | High | ⬜ | Instant purchase |
| Implement listing cancellation | Medium | ⬜ | Return item to seller |
| Implement auction processing task | High | ⬜ | Periodic job to end auctions |
| Implement bid notifications | Medium | ⬜ | Realtime outbid alerts |
| Implement my listings/bids API | Medium | ⬜ | User's active listings/bids |
| Create marketplace browse UI | High | ⬜ | `templates/marketplace/browse.html` |
| Create listing detail UI | High | ⬜ | Show item, bids, auction timer |
| Create listing creation UI | High | ⬜ | Form for creating listings |
| Test auction flow | High | ⬜ | Full auction cycle |
| Test tier restrictions | High | ⬜ | Verify Premium+ only |
**Deliverable:** Working marketplace system
--- ---
## Phase 9: Frontend Polish (Week 15-16) ### Phase 10: PWA & Deployment
**Goal:** Production deployment as Progressive Web App
**Goal:** Improve UI/UX, add HTMX interactivity [View Full Phase Document →](phases/Phase10-Deployment.md)
### Tasks - PWA with offline support
- Production infrastructure
| Task | Priority | Status | Notes | - Monitoring and backups
|------|----------|--------|-------|
| Design CSS theme | High | ⬜ | Dark fantasy aesthetic |
| Implement responsive design | High | ⬜ | Mobile-friendly |
| Add HTMX for dynamic updates | High | ⬜ | No full page reloads |
| Create reusable components | Medium | ⬜ | Character cards, inventory, etc. |
| Add loading states | Medium | ⬜ | Spinners for AI calls |
| Add error messaging | High | ⬜ | User-friendly error displays |
| Implement dice roll animations | Low | ⬜ | `static/js/dice-roller.js` |
| Add combat animations | Low | ⬜ | Visual feedback for actions |
| Create base template | High | ⬜ | `templates/base.html` |
| Test UI across browsers | Medium | ⬜ | Chrome, Firefox, Safari |
**Deliverable:** Polished, interactive UI
--- ---
## Phase 10: PWA & Deployment (Week 17-18) ### Phase 11: Beta Testing
**Goal:** Gather feedback and stabilize
**Goal:** Deploy to production as PWA [View Full Phase Document →](phases/Phase11-BetaTesting.md)
### Tasks - 10-20 beta testers
- Bug fixing
| Task | Priority | Status | Notes | - Balance tuning
|------|----------|--------|-------| - Performance optimization
| Create PWA manifest | High | ⬜ | `static/manifest.json` |
| Create service worker | High | ⬜ | `sw.js` for offline support |
| Create PWA icons | High | ⬜ | Various sizes |
| Set up production environment | High | ⬜ | Server, domain, SSL |
| Configure Nginx | High | ⬜ | Reverse proxy |
| Configure Gunicorn | High | ⬜ | 4+ workers |
| Set up RQ workers (production) | High | ⬜ | Separate instances |
| Set up Redis (production) | High | ⬜ | Standalone or cluster |
| Configure monitoring | High | ⬜ | Sentry, uptime monitoring |
| Set up backup system | High | ⬜ | Daily Appwrite backups |
| Create deployment scripts | High | ⬜ | `scripts/deploy.sh` |
| Write deployment documentation | Medium | ⬜ | Update DEPLOYMENT.md |
| Perform security audit | High | ⬜ | Check all endpoints |
| Load testing | Medium | ⬜ | Test concurrent users |
| Deploy to production | High | ⬜ | Go live! |
**Deliverable:** Live production deployment
--- ---
## Phase 11: Beta Testing & Iteration (Week 19-20) ### Phase 12: Launch Preparation
**Goal:** Marketing, payments, and public launch
**Goal:** Gather feedback and fix issues [View Full Phase Document →](phases/Phase12-Launch.md)
### Tasks - Landing page
- Stripe integration
| Task | Priority | Status | Notes | - Legal compliance
|------|----------|--------|-------| - Public launch
| Recruit beta testers | High | ⬜ | 10-20 users |
| Create feedback form | High | ⬜ | Bug reports, suggestions |
| Monitor error logs | High | ⬜ | Daily Sentry review |
| Monitor AI costs | High | ⬜ | Track spending |
| Fix critical bugs | High | ⬜ | Priority: game-breaking issues |
| Balance combat | Medium | ⬜ | Adjust damage, HP, difficulty |
| Balance economy | Medium | ⬜ | Gold rates, item prices |
| Optimize performance | Medium | ⬜ | Reduce latency |
| Improve AI prompts | Medium | ⬜ | Better narrative quality |
| Update documentation | Medium | ⬜ | Based on learnings |
**Deliverable:** Stable, tested system ready for launch
---
## Phase 12: Launch Preparation (Week 21-22)
**Goal:** Marketing, final polish, and launch
### Tasks
| Task | Priority | Status | Notes |
|------|----------|--------|-------|
| Create landing page | High | ⬜ | Marketing site |
| Write user documentation | High | ⬜ | How to play guide |
| Set up payment system | High | ⬜ | Stripe integration for subscriptions |
| Implement subscription tiers | High | ⬜ | Free/Basic/Premium/Elite |
| Create privacy policy | High | ⬜ | Legal requirement |
| Create terms of service | High | ⬜ | Legal requirement |
| Set up analytics | Medium | ⬜ | Track user behavior |
| Create social media accounts | Medium | ⬜ | Twitter, Discord, etc. |
| Write launch announcement | Medium | ⬜ | Blog post, social media |
| Set up support system | Medium | ⬜ | Email, Discord, or ticketing |
| Final security review | High | ⬜ | Penetration testing |
| Final performance review | High | ⬜ | Load testing |
| Launch! | High | ⬜ | Public release |
**Deliverable:** Public launch of Code of Conquest
--- ---
@@ -547,20 +179,7 @@ Phase 4 delivers the core single-player gameplay experience where players intera
### Phase 15: Mobile Apps ### Phase 15: Mobile Apps
- Native iOS app - Native iOS app
- Native Android app - Native Android app
- Mobile-specific UI optimizations - Mobile-specific optimizations
---
## Risk Management
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| **AI costs exceed budget** | High | Medium | Daily monitoring, strict tier limits, cost alerts |
| **Appwrite service issues** | High | Low | Backup plan to self-host, regular backups |
| **Low user adoption** | Medium | Medium | Beta testing, marketing, community building |
| **Performance issues** | Medium | Medium | Load testing, horizontal scaling, caching |
| **Security vulnerabilities** | High | Low | Regular audits, security-first development |
| **Feature creep** | Medium | High | Stick to roadmap, resist scope expansion |
--- ---
@@ -578,7 +197,7 @@ Phase 4 delivers the core single-player gameplay experience where players intera
- [ ] 100+ paying subscribers - [ ] 100+ paying subscribers
- [ ] Positive user reviews - [ ] Positive user reviews
- [ ] Profitable (revenue > costs) - [ ] Profitable (revenue > costs)
- [ ] Active community (Discord, etc.) - [ ] Active community
### Long-term Success (6 months post-launch) ### Long-term Success (6 months post-launch)
- [ ] 5,000+ registered users - [ ] 5,000+ registered users
@@ -589,45 +208,38 @@ Phase 4 delivers the core single-player gameplay experience where players intera
--- ---
## Notes ## Risk Management
**Current Status:** Phase 3 ✅ COMPLETE (100%) | Phase 4 🎯 READY TO START | Risk | Impact | Probability | Mitigation |
**Last Completed:** Phase 3 - Character System (creation, management, APIs, UI) - November 16, 2025 |------|--------|-------------|------------|
**Next Milestone:** Phase 4 - AI Integration + Story Progression + Quest System (Weeks 7-9, 21 days) | AI costs exceed budget | High | Medium | Daily monitoring, strict tier limits, cost alerts |
**Estimated MVP Completion:** End of Phase 10 (~20 weeks / 5 months) | Appwrite service issues | High | Low | Backup plan to self-host, regular backups |
**Estimated Launch:** End of Phase 12 (~24 weeks / 6 months) | Low user adoption | Medium | Medium | Beta testing, marketing, community building |
| Performance issues | Medium | Medium | Load testing, horizontal scaling, caching |
**Important Notes:** | Security vulnerabilities | High | Low | Regular audits, security-first development |
- Phase 3 is 100% complete. Skill tree UI deferred to Phase 5 (Combat System). | Feature creep | Medium | High | Stick to roadmap, resist scope expansion |
- Phase 4 is the next active development phase (AI engine + story progression + quests).
- Phase 4 expanded from 1 week to 3 weeks to include complete story gameplay loop.
- Overall timeline extended by 2 weeks from original estimate.
**Update Frequency:** Review and update roadmap weekly during development
--- ---
## Changelog ## Related Documentation
| Date | Change | By | **Project-Wide:**
|------|--------|-----| - [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture
| 2025-11-14 | Initial roadmap created | Claude | - [DEPLOYMENT.md](DEPLOYMENT.md) - Deployment guide
| 2025-11-14 | Phase 2 (Authentication & User Management) completed | User/Claude | - [VECTOR_DATABASE_STRATEGY.md](VECTOR_DATABASE_STRATEGY.md) - Lore system design
| 2025-11-15 | Phase 3 Character API layer completed (10 endpoints) | Claude |
| 2025-11-15 | Documentation updated: API_REFERENCE.md, API_TESTING.md | Claude | **API Backend:**
| 2025-11-16 | Phase 3 Integration tests completed (18 comprehensive tests) | Claude | - [API_REFERENCE.md](../api/docs/API_REFERENCE.md) - API endpoints
| 2025-11-16 | API_TESTING.md updated with correct endpoint paths and formats | Claude | - [DATA_MODELS.md](../api/docs/DATA_MODELS.md) - Data models
| 2025-11-16 | Database migrated from Documents API to TablesDB API | User/Claude | - [GAME_SYSTEMS.md](../api/docs/GAME_SYSTEMS.md) - Game mechanics
| 2025-11-16 | Authentication flow fixed (session secrets, cookies, user model) | User/Claude | - [QUEST_SYSTEM.md](../api/docs/QUEST_SYSTEM.md) - Quest specification
| 2025-11-16 | Phase 3 Character Creation UI completed (4-step HTMX flow) | User/Claude |
| 2025-11-16 | Phase 3 Character Management UI completed (list, detail pages) | User/Claude | **Web Frontend:**
| 2025-11-16 | Phase 3 COMPLETE ✅ - Character system fully functional | User/Claude | - [TEMPLATES.md](../public_web/docs/TEMPLATES.md) - Template structure
| 2025-11-16 | Created STORY_PROGRESSION.md - Turn-based story gameplay system | Claude | - [HTMX_PATTERNS.md](../public_web/docs/HTMX_PATTERNS.md) - HTMX patterns
| 2025-11-16 | Created QUEST_SYSTEM.md - YAML-driven quest system with context-aware offering | Claude |
| 2025-11-16 | Expanded Phase 4 from 1 week to 3 weeks (AI + Story + Quests) | Claude | ---
| 2025-11-16 | Adjusted Phases 5-6 timeline (+2 weeks overall to MVP) | Claude |
| 2025-11-16 | Created MULTIPLAYER.md - Invite-based, time-limited co-op system specification | Claude | **Document Version:** 2.0
| 2025-11-16 | Revised Phase 6: Multiplayer now paid-tier only, 2-hour sessions, AI campaigns | Claude | **Created:** November 2025
| 2025-11-16 | Phase 3 marked as 100% complete - Skill tree UI moved to Phase 5 | User/Claude | **Maintainer:** Development Team
| 2025-11-16 | Phase 5 updated to include skill tree UI (5 additional tasks) | User/Claude |
| 2025-11-16 | Phase 4 set as next active development phase (READY TO START) | User/Claude |

View File

@@ -0,0 +1,277 @@
# Phase 10: PWA & Deployment
**Goal:** Deploy to production as Progressive Web App
**Priority:** High
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
Prepare the application for production deployment as a Progressive Web App (PWA) with offline support, proper infrastructure, monitoring, and security hardening.
**Key Goals:**
- PWA with installability and offline support
- Production server setup (Nginx + Gunicorn)
- Monitoring and alerting (Sentry, uptime)
- Backup and disaster recovery
- Security audit and hardening
---
## Task Groups
### PWA Setup (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 10.1 | Create PWA manifest | ⬜ | `static/manifest.json` - name, icons, theme |
| 10.2 | Create service worker | ⬜ | `static/sw.js` - caching strategy, offline support |
| 10.3 | Create PWA icons | ⬜ | Various sizes: 72, 96, 128, 144, 152, 192, 384, 512 |
| 10.4 | **Checkpoint:** Test PWA installation | ⬜ | Install on mobile, verify offline behavior |
**Manifest Example:**
```json
{
"name": "Code of Conquest",
"short_name": "CoC",
"description": "AI-powered D&D adventure game",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#c9a227",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
```
**Deliverable:** Installable PWA
---
### Production Environment (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 10.5 | Set up production server | ⬜ | VPS/cloud instance, domain, SSL |
| 10.6 | Configure Nginx reverse proxy | ⬜ | SSL termination, static file serving |
| 10.7 | Configure Gunicorn | ⬜ | 4+ workers, production settings |
| 10.8 | Set up production Redis | ⬜ | Persistent storage, proper auth |
**Nginx Configuration (Example):**
```nginx
server {
listen 443 ssl http2;
server_name codeofconquest.com;
ssl_certificate /etc/letsencrypt/live/codeofconquest.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/codeofconquest.com/privkey.pem;
location /static {
alias /var/www/coc/public_web/static;
expires 1y;
}
location /api {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
**Deliverable:** Production server running
---
### Background Workers (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 10.9 | Set up RQ workers (production) | ⬜ | Systemd services, auto-restart |
| 10.10 | Configure worker monitoring | ⬜ | RQ dashboard or custom monitoring |
| 10.11 | Test job processing under load | ⬜ | Verify workers handle concurrent AI tasks |
**Systemd Service (Example):**
```ini
[Unit]
Description=RQ Worker for Code of Conquest
After=redis.service
[Service]
User=www-data
WorkingDirectory=/var/www/coc/api
ExecStart=/var/www/coc/api/venv/bin/rq worker ai_tasks combat_tasks
Restart=always
[Install]
WantedBy=multi-user.target
```
**Deliverable:** Reliable background job processing
---
### Monitoring & Alerting (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 10.12 | Set up Sentry for error tracking | ⬜ | API and web frontend integration |
| 10.13 | Set up uptime monitoring | ⬜ | External service (UptimeRobot, Better Uptime) |
| 10.14 | Configure AI cost monitoring | ⬜ | Daily spend alerts, tier limit tracking |
| 10.15 | **Checkpoint:** Verify alerting works | ⬜ | Trigger test alert, verify notification |
**Deliverable:** Comprehensive monitoring
---
### Backup & Security (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 10.16 | Set up daily Appwrite backups | ⬜ | Automated backup to cloud storage |
| 10.17 | Perform security audit | ⬜ | OWASP checklist, endpoint review |
| 10.18 | Configure rate limiting (production) | ⬜ | Flask-Limiter with production limits |
| 10.19 | Harden server security | ⬜ | Firewall, SSH keys, fail2ban |
**Security Checklist:**
- [ ] All endpoints require authentication where needed
- [ ] Input validation on all user inputs
- [ ] SQL/NoSQL injection prevention
- [ ] XSS prevention (output encoding)
- [ ] CSRF protection
- [ ] Rate limiting on AI endpoints
- [ ] Secrets in environment variables (not code)
- [ ] HTTPS enforced everywhere
- [ ] Security headers (CSP, HSTS, etc.)
**Deliverable:** Secured production environment
---
### Deployment Automation (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 10.20 | Create deployment script | ⬜ | `scripts/deploy.sh` - pull, build, restart |
| 10.21 | Write deployment documentation | ⬜ | Update `/docs/DEPLOYMENT.md` |
| 10.22 | **Final Checkpoint:** Deploy to production | ⬜ | Go live! |
**Deployment Script (Example):**
```bash
#!/bin/bash
set -e
echo "Pulling latest code..."
git pull origin master
echo "Updating API..."
cd /var/www/coc/api
source venv/bin/activate
pip install -r requirements.txt
sudo systemctl restart coc-api
sudo systemctl restart coc-worker
echo "Updating Web..."
cd /var/www/coc/public_web
source venv/bin/activate
pip install -r requirements.txt
sudo systemctl restart coc-web
echo "Deployment complete!"
```
**Deliverable:** Automated deployment process
---
## Files to Create/Modify
**New Files:**
- `/public_web/static/manifest.json`
- `/public_web/static/sw.js`
- `/public_web/static/icons/*.png` (various sizes)
- `/scripts/deploy.sh`
- `/scripts/backup.sh`
- `/config/nginx/coc.conf` (example Nginx config)
- `/config/systemd/coc-api.service`
- `/config/systemd/coc-web.service`
- `/config/systemd/coc-worker.service`
**Modified Files:**
- `/public_web/templates/base.html` - PWA meta tags, manifest link
- `/docs/DEPLOYMENT.md` - Production deployment guide
- `/api/app/__init__.py` - Sentry integration
- `/public_web/app/__init__.py` - Sentry integration
---
## Testing Criteria
### PWA Testing
- [ ] Manifest loads correctly
- [ ] Service worker registers
- [ ] App installable on mobile
- [ ] Offline page displays when disconnected
- [ ] PWA icons display correctly
### Production Testing
- [ ] HTTPS works correctly
- [ ] API accessible via domain
- [ ] Web frontend accessible via domain
- [ ] Static files served correctly
- [ ] Background workers processing jobs
### Security Testing
- [ ] Unauthorized access blocked
- [ ] Rate limiting working
- [ ] No sensitive data in responses
- [ ] Security headers present
### Monitoring Testing
- [ ] Sentry capturing errors
- [ ] Uptime monitoring active
- [ ] Alerts trigger correctly
---
## Success Criteria
- [ ] PWA installable and functional
- [ ] Production server running with SSL
- [ ] Background workers processing reliably
- [ ] Monitoring and alerting active
- [ ] Daily backups running
- [ ] Security audit passed
- [ ] Deployment automated
---
## Dependencies
**Requires (from earlier phases):**
- All core features complete
- Frontend polish complete
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| PWA Setup | 3 | 1 |
| Production Environment | 4 | 0 |
| Background Workers | 3 | 0 |
| Monitoring & Alerting | 3 | 1 |
| Backup & Security | 4 | 0 |
| Deployment Automation | 2 | 1 |
| **Total** | **19** | **3** |

View File

@@ -0,0 +1,210 @@
# Phase 11: Beta Testing & Iteration
**Goal:** Gather feedback, fix issues, balance gameplay
**Priority:** High
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
Launch a closed beta to gather real user feedback, identify bugs, balance gameplay mechanics, and optimize performance before public launch.
**Key Goals:**
- Recruit 10-20 beta testers
- Systematic feedback collection
- Bug fixing and stabilization
- Combat and economy balancing
- Performance optimization
- AI prompt improvements
---
## Task Groups
### Beta Recruitment (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.1 | Create beta signup form | ⬜ | Collect email, gaming experience, availability |
| 11.2 | Recruit 10-20 beta testers | ⬜ | Friends, communities, Discord |
| 11.3 | Create beta tester onboarding | ⬜ | Welcome email, how to give feedback, known issues |
**Beta Tester Profile:**
- Mix of casual and hardcore gamers
- Some D&D/RPG experience helpful
- Willing to provide detailed feedback
- Available for 1-2 weeks of testing
**Deliverable:** Active beta tester group
---
### Feedback Collection (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.4 | Create feedback form | ⬜ | Bug reports, suggestions, ratings |
| 11.5 | Set up Discord server for testers | ⬜ | Channels: bugs, suggestions, general |
| 11.6 | **Checkpoint:** Collect first round of feedback | ⬜ | After 3-5 days of testing |
**Feedback Categories:**
- **Bugs:** Crashes, errors, broken features
- **UX Issues:** Confusing UI, unclear instructions
- **Balance:** Too easy, too hard, unfair
- **Suggestions:** Feature requests, improvements
- **AI Quality:** Narrative quality, NPC responses
**Deliverable:** Organized feedback pipeline
---
### Monitoring & Analysis (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.7 | Monitor Sentry error logs daily | ⬜ | Prioritize critical and frequent errors |
| 11.8 | Monitor AI costs | ⬜ | Track daily spending, adjust limits if needed |
| 11.9 | Analyze usage patterns | ⬜ | Popular features, drop-off points, session length |
**Deliverable:** Data-driven insights
---
### Bug Fixing (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.10 | Triage and prioritize bugs | ⬜ | Critical → High → Medium → Low |
| 11.11 | Fix critical and high priority bugs | ⬜ | Game-breaking issues first |
| 11.12 | Fix medium priority bugs | ⬜ | UX issues, minor glitches |
**Priority Definitions:**
- **Critical:** Game unplayable, data loss, security issues
- **High:** Major feature broken, frequent crashes
- **Medium:** Minor feature broken, visual glitches
- **Low:** Edge cases, cosmetic issues
**Deliverable:** Stable, bug-free experience
---
### Balancing (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.13 | Balance combat difficulty | ⬜ | Adjust enemy HP, damage, encounter rates |
| 11.14 | Balance economy | ⬜ | Gold rewards, item prices, quest rewards |
| 11.15 | **Checkpoint:** Verify balance feels right | ⬜ | Tester feedback on difficulty |
**Balancing Metrics:**
- Average combat win rate: 70-80%
- Time to level up: ~30 minutes per level
- Gold earned vs. needed for upgrades
- Quest completion rates
**Deliverable:** Balanced gameplay
---
### Optimization (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.16 | Optimize slow API endpoints | ⬜ | Profile and improve <200ms target |
| 11.17 | Optimize AI prompts for quality/cost | ⬜ | Better prompts, lower token usage |
| 11.18 | Reduce page load times | ⬜ | Asset optimization, caching |
**Performance Targets:**
- API response time: <200ms (non-AI)
- AI response time: <3s
- Page load time: <2s
- Time to interactive: <1s
**Deliverable:** Optimized performance
---
### Documentation Update (2 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 11.19 | Update documentation based on learnings | ⬜ | API docs, user guide, troubleshooting |
| 11.20 | **Final Checkpoint:** Beta sign-off | ⬜ | All critical issues resolved |
**Deliverable:** Updated documentation
---
## Files to Create/Modify
**New Files:**
- `/docs/BETA_FEEDBACK.md` - Aggregated beta feedback
- `/docs/KNOWN_ISSUES.md` - Known issues and workarounds
**Modified Files:**
- Various bug fixes across codebase
- `/api/app/data/enemies/*.yaml` - Balance adjustments
- `/api/app/data/quests/*.yaml` - Reward adjustments
- `/api/app/ai/prompts.py` - Prompt improvements
---
## Testing Criteria
### Bug Verification
- [ ] All critical bugs fixed
- [ ] All high priority bugs fixed
- [ ] Medium priority bugs addressed or documented
### Balance Verification
- [ ] Combat feels challenging but fair
- [ ] Economy feels rewarding
- [ ] Progression pace feels good
### Performance Verification
- [ ] API endpoints meet targets
- [ ] AI responses acceptable
- [ ] No noticeable lag in UI
### Beta Tester Satisfaction
- [ ] Overall positive feedback
- [ ] Testers would recommend to friends
- [ ] Major concerns addressed
---
## Success Criteria
- [ ] 10-20 active beta testers
- [ ] Feedback collection system working
- [ ] Zero critical bugs
- [ ] < 5 high priority bugs remaining
- [ ] Combat balanced (70-80% win rate)
- [ ] Economy balanced
- [ ] Performance targets met
- [ ] Positive beta tester sentiment
---
## Dependencies
**Requires (from earlier phases):**
- Production deployment (Phase 10)
- All features implemented
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| Beta Recruitment | 3 | 0 |
| Feedback Collection | 2 | 1 |
| Monitoring & Analysis | 3 | 0 |
| Bug Fixing | 3 | 0 |
| Balancing | 2 | 1 |
| Optimization | 3 | 0 |
| Documentation Update | 1 | 1 |
| **Total** | **17** | **3** |

View File

@@ -0,0 +1,247 @@
# Phase 12: Launch Preparation
**Goal:** Marketing, final polish, and public launch
**Priority:** High
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
Prepare for public launch with marketing materials, user documentation, payment integration, legal compliance, and final quality assurance.
**Key Goals:**
- Marketing landing page
- User documentation (how to play)
- Payment integration (Stripe subscriptions)
- Legal compliance (privacy policy, ToS)
- Final security and performance review
- Public launch
---
## Task Groups
### Marketing & Landing Page (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.1 | Design landing page | ⬜ | Hero, features, pricing, CTA |
| 12.2 | Create landing page | ⬜ | `/public_web/templates/landing.html` |
| 12.3 | Create marketing screenshots/videos | ⬜ | Gameplay screenshots, demo video |
| 12.4 | Set up social media accounts | ⬜ | Twitter/X, Discord, Reddit |
**Landing Page Sections:**
- Hero: Tagline, CTA, hero image
- Features: AI DM, character creation, combat, quests
- Pricing: Free, Premium, Elite tiers
- FAQ: Common questions
- Footer: Links, legal, social
**Deliverable:** Public-facing marketing presence
---
### User Documentation (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.5 | Write "How to Play" guide | ⬜ | Getting started, character creation, gameplay |
| 12.6 | Write FAQ | ⬜ | Common questions and answers |
| 12.7 | **Checkpoint:** Review documentation | ⬜ | Test with new user |
**Documentation Topics:**
- Account creation
- Character creation
- Gameplay basics (story, combat, quests)
- Subscription tiers
- Troubleshooting
**Deliverable:** User-friendly documentation
---
### Payment Integration (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.8 | Set up Stripe account | ⬜ | Business account, test mode first |
| 12.9 | Create subscription products | ⬜ | Free, Premium ($X/mo), Elite ($X/mo) |
| 12.10 | Implement subscription endpoints | ⬜ | Create, cancel, webhook handling |
| 12.11 | Create subscription management UI | ⬜ | Upgrade, downgrade, cancel |
**Subscription Tiers:**
| Tier | Price | Features |
|------|-------|----------|
| Free | $0 | Basic gameplay, limited AI calls |
| Premium | $X/mo | Unlimited AI, marketplace, multiplayer |
| Elite | $X/mo | All features, priority support, exclusive content |
**Stripe Integration:**
```python
# Webhook endpoint for subscription events
@app.route('/api/v1/webhooks/stripe', methods=['POST'])
def stripe_webhook():
event = stripe.Webhook.construct_event(...)
if event['type'] == 'customer.subscription.updated':
# Update user tier in database
return '', 200
```
**Deliverable:** Working payment system
---
### Legal Compliance (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.12 | Create Privacy Policy | ⬜ | Data collection, usage, GDPR compliance |
| 12.13 | Create Terms of Service | ⬜ | User responsibilities, liability, refunds |
| 12.14 | Add cookie consent banner | ⬜ | If using analytics/tracking cookies |
**Deliverable:** Legal compliance
---
### Analytics & Support (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.15 | Set up analytics | ⬜ | Privacy-friendly (Plausible, Fathom) or GA |
| 12.16 | Set up support system | ⬜ | Email support, Discord, or ticketing |
| 12.17 | Create support documentation | ⬜ | How to contact, response times |
**Deliverable:** User analytics and support
---
### Final Reviews (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.18 | Final security review | ⬜ | Penetration testing, OWASP check |
| 12.19 | Final performance review | ⬜ | Load testing with expected traffic |
| 12.20 | **Checkpoint:** Launch readiness check | ⬜ | All systems go |
**Launch Readiness Checklist:**
- [ ] All features working
- [ ] No critical bugs
- [ ] Payment system tested
- [ ] Legal pages in place
- [ ] Monitoring active
- [ ] Backups running
- [ ] Support ready
- [ ] Marketing ready
**Deliverable:** Launch-ready application
---
### Launch (2 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 12.21 | Write launch announcement | ⬜ | Blog post, social media posts |
| 12.22 | **LAUNCH!** | ⬜ | Go live, announce, celebrate! |
**Launch Day Checklist:**
1. Final backup before launch
2. Remove any beta/test restrictions
3. Enable payment processing (live mode)
4. Post announcement on all channels
5. Monitor systems closely for first 24 hours
6. Respond quickly to any issues
**Deliverable:** Public launch of Code of Conquest
---
## Files to Create/Modify
**New Files:**
- `/public_web/templates/landing.html` - Marketing landing page
- `/public_web/templates/pricing.html` - Pricing page
- `/public_web/templates/legal/privacy.html` - Privacy policy
- `/public_web/templates/legal/terms.html` - Terms of service
- `/public_web/templates/docs/how-to-play.html` - User guide
- `/public_web/templates/docs/faq.html` - FAQ
- `/api/app/api/subscriptions.py` - Subscription endpoints
- `/api/app/api/webhooks.py` - Stripe webhook handling
- `/api/app/services/subscription_service.py` - Subscription logic
**Modified Files:**
- `/api/app/__init__.py` - Register subscription routes
- `/api/config/*.yaml` - Stripe configuration
- `/public_web/templates/base.html` - Analytics, cookie consent
---
## Testing Criteria
### Payment Testing
- [ ] Subscription creation works (test mode)
- [ ] Subscription cancellation works
- [ ] Webhooks received and processed
- [ ] Tier changes reflected immediately
### Legal Review
- [ ] Privacy policy covers all data practices
- [ ] Terms of service protect business
- [ ] Cookie consent compliant
### Final Testing
- [ ] Load test passed
- [ ] Security test passed
- [ ] All features working end-to-end
- [ ] New user can complete full flow
---
## Success Criteria
- [ ] Landing page live and attractive
- [ ] User documentation complete
- [ ] Payment system working (Stripe live)
- [ ] Legal pages in place
- [ ] Analytics tracking
- [ ] Support system ready
- [ ] Security review passed
- [ ] Performance review passed
- [ ] Successful public launch
---
## Dependencies
**Requires (from earlier phases):**
- Beta testing complete (Phase 11)
- All bugs fixed
- Performance optimized
---
## Post-Launch Priorities
After launch, focus on:
1. Monitor systems closely (first 48 hours critical)
2. Respond to user feedback quickly
3. Fix any launch-day bugs immediately
4. Engage with community on Discord/social
5. Track key metrics (signups, conversions, retention)
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| Marketing & Landing Page | 4 | 0 |
| User Documentation | 2 | 1 |
| Payment Integration | 4 | 0 |
| Legal Compliance | 3 | 0 |
| Analytics & Support | 3 | 0 |
| Final Reviews | 2 | 1 |
| Launch | 1 | 1 |
| **Total** | **19** | **3** |

View File

@@ -0,0 +1,352 @@
# Phase 5: Quest System
**Goal:** YAML-driven quest system with context-aware offering
**Priority:** High
**Status:** Complete (100%)
**Last Updated:** November 29, 2025
---
## Overview
The Quest System provides structured objectives and rewards for players during their solo story progression sessions. Quests are defined in YAML files and offered to players by the AI Dungeon Master based on context-aware triggers and location-based probability.
**Key Principles:**
- **YAML-driven design** - Quests defined in data files, no code changes needed
- **Context-aware offering** - AI analyzes narrative context to offer relevant quests
- **Location-based triggers** - Different areas have different quest probabilities
- **Max 2 active quests** - Prevents player overwhelm
- **Rewarding progression** - Quests provide gold, XP, and items
**Reference:** See `/api/docs/QUEST_SYSTEM.md` for detailed technical specification.
---
## Task Groups
### Quest Data Models (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.1 | Create Quest dataclass | ✅ | `/api/app/models/quest.py` - Quest, status, progress tracking |
| 5.2 | Create QuestObjective, QuestReward, QuestTriggers dataclasses | ✅ | Objective types: kill, collect, travel, interact, discover |
| 5.3 | **Checkpoint:** Verify serialization | ✅ | Test round-trip to JSON with `to_dict()` / `from_dict()` |
**Deliverable:** Quest data models with full serialization support ✅
---
### Quest Content & Loading (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.4 | Create quest YAML schema | ✅ | Quest-centric design where quests define their NPC givers |
| 5.5 | Create `/api/app/data/quests/` directory structure | ✅ | Organized by difficulty: `easy/`, `medium/`, `hard/`, `epic/` |
| 5.6 | Write example quests in YAML | ✅ | 5 example quests created (2 easy, 2 medium, 1 hard) |
| 5.7 | Implement QuestService (QuestLoader) | ✅ | `/api/app/services/quest_service.py` - YAML loading, caching |
**Example Quest Structure:**
```yaml
quest_id: "quest_rats_tavern"
name: "Rat Problem"
description: "The local tavern is overrun with giant rats..."
quest_giver: "Tavern Keeper"
difficulty: "easy"
objectives:
- objective_id: "kill_rats"
description: "Kill 10 giant rats in the tavern basement"
objective_type: "kill"
required_progress: 10
rewards:
gold: 50
experience: 100
items: []
offering_triggers:
location_types: ["town"]
min_character_level: 1
max_character_level: 3
probability_weights:
town: 0.30
narrative_hooks:
- "The tavern keeper frantically waves you over..."
```
**Deliverable:** 5+ quests loadable from YAML ✅
---
### Quest Eligibility & Offering (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.8 | Implement QuestEligibilityService | ✅ | `/api/app/services/quest_eligibility_service.py` - Core eligibility logic |
| 5.9 | Implement `get_quests_for_npc()` | ✅ | Find quests where NPC is quest_giver |
| 5.10 | Implement probability roll + filtering | ✅ | Location-based weights, level check, status check |
**Deliverable:** Context-aware quest eligibility system ✅
---
### Lore Service Stub (2 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.11 | Create LoreService interface | ✅ | `/api/app/services/lore_service.py` - Abstract interface for Phase 6 |
| 5.12 | Implement MockLoreService | ✅ | Returns quest's embedded lore_context |
**Deliverable:** Lore service stub ready for Phase 6 Weaviate integration ✅
---
### AI Prompt Integration (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.13 | Add quest offering section to template | ✅ | `/api/app/ai/templates/npc_dialogue.j2` - Quest context + natural weaving |
| 5.14 | Add lore context section to template | ✅ | Filtered lore for NPC knowledge |
| 5.15 | Implement quest offer parsing | ✅ | `/api/app/ai/response_parser.py` - Extract `[QUEST_OFFER:quest_id]` markers |
**Deliverable:** AI naturally weaves quest offers into NPC dialogue ✅
---
### NPC API Integration (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.16 | Integrate eligibility check into `talk_to_npc` | ✅ | `/api/app/api/npcs.py` - Check before building AI context |
| 5.17 | Add quest context to AI task | ✅ | `/api/app/tasks/ai_tasks.py` - Modify `_process_npc_dialogue_task` |
| 5.18 | Handle quest_offered in response | ✅ | Parse and include in API response |
| 5.19 | Remove `quest_giver_for` from NPC model | ✅ | Quest-centric: quests now define their givers |
**Deliverable:** Quest offering integrated into NPC conversation flow ✅
---
### Quest Accept/Manage Endpoints (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.20 | Create quests blueprint | ✅ | `/api/app/api/quests.py` - Registered in `__init__.py` |
| 5.21 | Implement `POST /api/v1/quests/accept` | ✅ | Add to active_quests, update relationship |
| 5.22 | Implement `POST /api/v1/quests/decline` | ✅ | Set `refused_{quest_id}` flag |
| 5.23 | Implement `GET /api/v1/characters/{id}/quests` | ✅ | List active and completed quests |
**Deliverable:** Quest management API endpoints ✅
---
### Testing & Validation (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.24 | Unit tests for Quest models | ✅ | `/api/tests/test_quest_models.py` - Serialization, validation |
| 5.25 | Unit tests for QuestEligibilityService | ✅ | `/api/tests/test_quest_eligibility.py` - Filtering logic |
| 5.26 | Integration test: full quest offer flow | ✅ | `/api/tests/test_quest_integration.py` - NPC talk → offer → accept |
**Deliverable:** Comprehensive test coverage ✅
---
### Quest UI & Final Testing (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 5.27 | Create quest tracker sidebar UI | ✅ | `public_web/templates/game/partials/sidebar_quests.html` - Enhanced with HTMX |
| 5.28 | Create quest offering modal UI | ✅ | `public_web/templates/game/partials/quest_offer_modal.html` |
| 5.29 | Create quest detail view | ✅ | `public_web/templates/game/partials/quest_detail_modal.html` |
| 5.30 | **Final Checkpoint:** Full integration test | ✅ | Complete quest lifecycle: offer → accept → progress → complete |
**Deliverable:** Working quest UI with HTMX integration ✅
---
## API Endpoints
| Method | Endpoint | Description | Status |
|--------|----------|-------------|--------|
| `POST` | `/api/v1/quests/accept` | Accept a quest offer | ✅ |
| `POST` | `/api/v1/quests/decline` | Decline a quest offer | ✅ |
| `GET` | `/api/v1/characters/{id}/quests` | Get character's active and completed quests | ✅ |
| `POST` | `/api/v1/quests/progress` | Update quest objective progress | ✅ |
| `GET` | `/api/v1/quests/{quest_id}` | Get quest details | ✅ |
| `POST` | `/api/v1/quests/complete` | Complete a quest and claim rewards | ✅ |
| `POST` | `/api/v1/quests/abandon` | Abandon an active quest | ✅ |
---
## Files Created/Modified
**New Files Created:**
-`/api/app/models/quest.py` - Quest, QuestObjective, QuestReward, QuestTriggers
-`/api/app/services/quest_service.py` - QuestService class with YAML loading
-`/api/app/services/quest_eligibility_service.py` - QuestEligibilityService with filtering
-`/api/app/services/lore_service.py` - LoreService interface + MockLoreService stub
-`/api/app/api/quests.py` - Quest API blueprint with progress endpoint
-`/api/app/data/quests/easy/*.yaml` - Easy quests (2)
-`/api/app/data/quests/medium/*.yaml` - Medium quests (2)
-`/api/app/data/quests/hard/*.yaml` - Hard quests (1)
-`/api/tests/test_quest_models.py` - Quest model unit tests
-`/api/tests/test_quest_integration.py` - Integration tests (expanded)
-`/public_web/templates/game/partials/quest_offer_modal.html` - Quest offer modal
-`/public_web/templates/game/partials/quest_detail_modal.html` - Quest detail modal
**Modified Files:**
-`/api/app/models/character.py` - Added `active_quests`, `completed_quests` fields
-`/api/app/api/npcs.py` - Integrated quest eligibility into `talk_to_npc`
-`/api/app/tasks/ai_tasks.py` - Added quest context to dialogue generation
-`/api/app/ai/templates/npc_dialogue.j2` - Added quest offering section
-`/api/app/ai/response_parser.py` - Added quest offer parsing
-`/api/app/__init__.py` - Registered quests blueprint
-`/api/app/services/combat_service.py` - Quest progress on enemy kills
-`/public_web/templates/game/partials/sidebar_quests.html` - Enhanced with HTMX
-`/public_web/app/views/game_views.py` - Quest routes and modal handlers
-`/public_web/static/css/play.css` - Quest modal and sidebar styles
---
## Testing Criteria
### Unit Tests
- [x] Quest loading from YAML
- [x] Quest serialization (to_dict/from_dict)
- [x] QuestEligibilityService filtering
- [x] Probability roll logic
- [x] Quest.is_complete() logic
- [x] Quest.update_progress() logic
### Integration Tests
- [x] Quest offer during NPC talk
- [x] Accept quest (add to active_quests)
- [x] Decline quest (set refused flag)
- [x] Quest limit enforced (max 2 active)
- [x] Quest progress updates during combat
- [x] Complete quest and receive rewards
- [x] Level up from quest XP
- [x] Abandon quest
### Manual Testing
- [ ] Full quest flow (offer → accept → progress → complete)
- [ ] Multiple quests active simultaneously
- [ ] Quest offering feels natural in narrative
- [ ] UI components display correctly
---
## Success Criteria
- [x] Quest data models implemented and tested
- [x] QuestService loads quests from YAML files (5 quests created)
- [x] Quest offering integrated into NPC conversations
- [x] Context-aware quest selection working (eligibility + probability)
- [x] Max 2 active quests enforced
- [x] LoreService stub ready for Phase 6 integration
- [x] AI naturally weaves quest offers into dialogue
- [x] Quest offer parsing extracts `[QUEST_OFFER:id]` correctly
- [x] Accept/decline endpoints working
- [x] Quest progress updates automatically (combat kill tracking)
- [x] Quest completion grants rewards correctly
- [x] Quest UI components functional
---
## Dependencies
**Requires (already implemented):**
- Session system (`/api/app/services/session_service.py`)
- Character system (`/api/app/models/character.py`)
- Combat system (`/api/app/services/combat_service.py`)
- Location system (`/api/app/services/location_loader.py`)
- NPC system (`/api/app/services/npc_loader.py`)
**Enables (future phases):**
- Phase 6: Story Progression (quests provide structured objectives)
- Phase 7: Multiplayer (party quests)
---
## Task Summary
| Group | Tasks | Completed | Status |
|-------|-------|-----------|--------|
| Quest Data Models | 3 | 3 | ✅ |
| Quest Content & Loading | 4 | 4 | ✅ |
| Quest Eligibility & Offering | 3 | 3 | ✅ |
| Lore Service Stub | 2 | 2 | ✅ |
| AI Prompt Integration | 3 | 3 | ✅ |
| NPC API Integration | 4 | 4 | ✅ |
| Quest Accept/Manage Endpoints | 4 | 4 | ✅ |
| Testing & Validation | 3 | 3 | ✅ |
| Quest UI & Final Testing | 4 | 4 | ✅ |
| **Total** | **30** | **30** | **100%** |
---
## Architecture Notes
### Quest-Centric Design
The implementation follows a quest-centric approach where:
- **Quests define their NPC givers** via `quest_giver_npc_ids` field
- Adding new quests doesn't require modifying NPC files
- NPCs automatically discover which quests they can offer
### NPC Conversation Flow
```
POST /api/v1/npcs/{npc_id}/talk
┌─────────────────────────────┐
│ 1. Load Context │
│ - NPC, Character, Location│
└────────────┬────────────────┘
┌─────────────────────────────┐
│ 2. Quest Eligibility Check │
│ QuestEligibilityService │
│ - Find quests where NPC │
│ is quest_giver │
│ - Filter by char level, │
│ completed, active, etc │
└────────────┬────────────────┘
┌─────────────────────────────┐
│ 3. Probability Roll │
│ - Town: 30%, Tavern: 35% │
│ - Wilderness: 5% │
│ - If fail → no offer │
└────────────┬────────────────┘
│ (if success)
┌─────────────────────────────┐
│ 4. Get Lore Context (stub) │
│ LoreService.get_context()│
│ - Quest embedded lore │
│ - Mock regional lore │
└────────────┬────────────────┘
┌─────────────────────────────┐
│ 5. AI Generates Dialogue │
│ Naturally weaves quest │
│ offer into conversation │
│ Includes [QUEST_OFFER:id]│
└────────────┬────────────────┘
┌─────────────────────────────┐
│ 6. Parse Response │
│ Extract quest_offered │
│ Return to frontend │
└─────────────────────────────┘
```
### Phase 6 Integration Points
The LoreService stub is ready for Phase 6 Weaviate integration:
- Replace `MockLoreService` with `WeaviateLoreService`
- Quest's embedded `lore_context` will be supplemented with vector DB queries
- NPC dialogue template already has lore context section

View File

@@ -0,0 +1,354 @@
# Phase 6: Story Progression & Lore System
**Goal:** Vector database-powered NPC knowledge and world lore
**Priority:** High
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
Implement a layered knowledge system using vector databases (Weaviate) to provide NPCs and the Dungeon Master with contextual lore, regional history, and world knowledge. This enhances NPC conversations with authentic, contextual responses.
**Key Principles:**
- **Three-tier knowledge hierarchy** - World Lore → Regional Lore → NPC Persona
- **RAG (Retrieval-Augmented Generation)** - Semantic search for relevant context
- **Knowledge boundaries** - NPCs only know what they should know
- **Index-once strategy** - Embeddings generated at build time, not runtime
**Reference:** See `/docs/VECTOR_DATABASE_STRATEGY.md` for detailed architecture.
---
## Knowledge Hierarchy
### Three-Tier Structure
1. **World Lore DB** (Global)
- Broad historical events, mythology, major kingdoms, legendary figures
- Accessible to all NPCs and DM for player questions
- Examples: "The Great War 200 years ago", "The origin of magic"
2. **Regional/Town Lore DB** (Location-specific)
- Local history, notable events, landmarks, politics, rumors
- Current town leadership, recent events, local legends
- Trade routes, neighboring settlements, regional conflicts
3. **NPC Persona** (Individual, YAML-defined)
- Personal background, personality, motivations
- Specific knowledge based on profession/role
- Already implemented in `/api/app/data/npcs/*.yaml`
---
## Task Groups
### Vector Database Setup (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.1 | Add Weaviate to Docker Compose | ⬜ | Self-hosted for development |
| 6.2 | Create Weaviate service configuration | ⬜ | `/api/config/*.yaml` - dev/prod endpoints |
| 6.3 | Create WeaviateService class | ⬜ | `/api/app/services/weaviate_service.py` - connection, health check |
| 6.4 | **Checkpoint:** Verify Weaviate connectivity | ⬜ | Test connection and basic operations |
**Docker Compose Addition:**
```yaml
services:
weaviate:
image: semitechnologies/weaviate:latest
ports:
- "8080:8080"
environment:
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
volumes:
- weaviate_data:/var/lib/weaviate
```
**Deliverable:** Working Weaviate instance in development environment
---
### Weaviate Schema & Collections (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.5 | Define WorldLore collection schema | ⬜ | Metadata: knowledge_type, time_period, required_profession |
| 6.6 | Define RegionalLore collection schema | ⬜ | Metadata: region_id, knowledge_type, social_class |
| 6.7 | Create schema initialization script | ⬜ | `/api/scripts/init_weaviate_schema.py` |
| 6.8 | **Checkpoint:** Verify collections created | ⬜ | Test schema via Weaviate console |
**Collection Schema:**
```
WorldLore:
- content: text (vectorized)
- title: string
- knowledge_type: string (academic, common, secret)
- time_period: string (ancient, historical, recent, current)
- required_profession: string[] (optional filter)
- tags: string[]
RegionalLore:
- content: text (vectorized)
- title: string
- region_id: string
- knowledge_type: string
- social_class: string[] (noble, merchant, commoner)
- tags: string[]
```
**Deliverable:** Weaviate schema ready for data ingestion
---
### World Lore Content (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.9 | Create lore content directory structure | ⬜ | `/api/app/data/lore/world/` |
| 6.10 | Write world history YAML | ⬜ | Major events, wars, kingdoms, mythology |
| 6.11 | Write mythology YAML | ⬜ | Gods, creation stories, magic origins |
| 6.12 | Write kingdoms YAML | ⬜ | Major factions, political landscape |
**Directory Structure:**
```
/api/app/data/lore/
world/
history.yaml # Major historical events
mythology.yaml # Gods, legends, magic
kingdoms.yaml # Factions, politics
regions/
crossville/
history.yaml # Local history
locations.yaml # Notable places
rumors.yaml # Current gossip
```
**Example Lore Entry:**
```yaml
- id: "great_war"
title: "The Great War of the Five Kingdoms"
content: |
Two hundred years ago, the Five Kingdoms united against
the Shadow Empire in a conflict that reshaped the world...
metadata:
knowledge_type: "common"
time_period: "historical"
required_profession: null
tags:
- "war"
- "five-kingdoms"
- "shadow-empire"
```
**Deliverable:** 20+ world lore entries ready for embedding
---
### Regional Lore Content (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.13 | Write Crossville regional history | ⬜ | Founding, notable events, local legends |
| 6.14 | Write Crossville locations lore | ⬜ | Descriptions for tavern, forest, dungeon |
| 6.15 | Write Crossville rumors | ⬜ | Current gossip, quest hooks |
**Deliverable:** 15+ regional lore entries for starter region
---
### Embedding Generation (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.16 | Create embedding generation script | ⬜ | `/api/scripts/generate_lore_embeddings.py` |
| 6.17 | Implement chunking strategy | ⬜ | Split by logical units (events, locations, figures) |
| 6.18 | Generate and upload world lore embeddings | ⬜ | One-time generation, upload to Weaviate |
| 6.19 | **Checkpoint:** Verify embedding quality | ⬜ | Test semantic search returns relevant results |
**Embedding Model Options:**
- **Development:** sentence-transformers (free, fast iteration)
- **Production:** OpenAI text-embedding-3-large or Replicate multilingual-e5-large
**Deliverable:** All lore content embedded and searchable in Weaviate
---
### NPC Knowledge Integration (5 tasks)
> **Note:** The LoreService interface and MockLoreService stub were created in Phase 5 (`/api/app/services/lore_service.py`). This phase replaces the mock with the real Weaviate implementation.
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.20 | Implement WeaviateLoreService | ⬜ | Replace MockLoreService, query World + Regional DBs |
| 6.21 | Add knowledge filtering by NPC role | ⬜ | Profession, social class, relationship level |
| 6.22 | Integrate lore into NPC conversation prompts | ⬜ | RAG pattern: retrieve → inject → generate |
| 6.23 | Implement "I don't know" responses | ⬜ | Authentic ignorance when NPC lacks knowledge |
| 6.24 | **Checkpoint:** Test NPC conversations with lore | ⬜ | Verify NPCs use contextual knowledge |
**RAG Pattern for NPC Dialogue:**
```
[NPC Persona from YAML]
+
[Top 3-5 relevant chunks from Regional DB]
+
[Top 2-3 relevant chunks from World Lore if historical topic]
+
[Conversation history]
→ Feed to Claude with instruction to stay in character
```
**Knowledge Filtering Example:**
```python
# Blacksmith asking about metallurgy → Return results
# Blacksmith asking about court politics → "I don't know about that"
def filter_knowledge_for_npc(results, npc):
return [r for r in results if npc_can_know(r.metadata, npc)]
```
**Deliverable:** NPCs respond with contextually appropriate lore
---
### DM Lore Access (2 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 6.25 | Implement DM lore query endpoint | ⬜ | `POST /api/v1/lore/query` - Full access, no filtering |
| 6.26 | Integrate lore into story narration | ⬜ | DM uses lore for atmospheric descriptions |
**DM vs NPC Knowledge:**
- **DM Mode:** Access to ALL databases without restrictions
- **NPC Mode:** Knowledge filtered by persona/role/location
**Deliverable:** DM can access all lore for narrative purposes
---
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/lore/query` | Query lore by topic (DM access) |
| `GET` | `/api/v1/lore/regions/{region_id}` | Get all lore for a region |
| `GET` | `/api/v1/lore/world` | Get world lore summary |
---
## Files to Create/Modify
**New Files:**
- `/api/app/services/weaviate_service.py` - Weaviate connection and queries
- `/api/scripts/init_weaviate_schema.py` - Schema initialization
- `/api/scripts/generate_lore_embeddings.py` - Embedding generation
- `/api/app/data/lore/world/history.yaml`
- `/api/app/data/lore/world/mythology.yaml`
- `/api/app/data/lore/world/kingdoms.yaml`
- `/api/app/data/lore/regions/crossville/history.yaml`
- `/api/app/data/lore/regions/crossville/locations.yaml`
- `/api/app/data/lore/regions/crossville/rumors.yaml`
- `/api/app/api/lore.py` - Lore API blueprint
**Modified Files:**
- `/api/app/services/lore_service.py` - Already exists from Phase 5 (stub). Add WeaviateLoreService implementation
- `/docker-compose.yml` - Add Weaviate service
- `/api/config/development.yaml` - Weaviate dev config
- `/api/config/production.yaml` - Weaviate Cloud Services config
- `/api/app/services/npc_loader.py` - Integrate lore queries
- `/api/app/ai/prompts.py` - Add lore context to prompts
---
## Testing Criteria
### Unit Tests
- [ ] Weaviate connection and health check
- [ ] Schema creation
- [ ] Lore content loading from YAML
- [ ] Embedding generation
- [ ] Semantic search returns relevant results
- [ ] Knowledge filtering by NPC role
### Integration Tests
- [ ] Full RAG pipeline: query → retrieve → inject → generate
- [ ] NPC conversation includes relevant lore
- [ ] NPC correctly says "I don't know" for unknown topics
- [ ] DM has full lore access
### Manual Testing
- [ ] Ask NPC about local history → Returns regional lore
- [ ] Ask NPC about world history → Returns filtered world lore
- [ ] Ask farmer about court politics → "I wouldn't know about that"
- [ ] Ask scholar about ancient history → Detailed response
---
## Success Criteria
- [ ] Weaviate running in development environment
- [ ] 20+ world lore entries embedded
- [ ] 15+ regional lore entries for Crossville
- [ ] NPC conversations enhanced with contextual lore
- [ ] Knowledge filtering working by profession/class
- [ ] DM has full lore access for narration
- [ ] Semantic search returning relevant results
---
## Dependencies
**Requires (already implemented):**
- NPC system (`/api/app/services/npc_loader.py`)
- Location system (`/api/app/services/location_loader.py`)
- AI service (`/api/app/ai/`)
**Requires (from Phase 5):**
- Quest system (quests can reference lore) ✅
- LoreService interface stub (`/api/app/services/lore_service.py`) ✅ - Ready to replace MockLoreService with WeaviateLoreService
**Enables (future phases):**
- Phase 7: Multiplayer (shared world lore)
- Lore codex/discovery system (optional future feature)
---
## Production Migration Path
### Zero-Code Migration to Weaviate Cloud Services
1. Export data from self-hosted Weaviate
2. Create Weaviate Cloud Services cluster
3. Import data to WCS
4. Change environment variable: `WEAVIATE_URL`
5. Deploy (no code changes required)
**Environment Configuration:**
```yaml
# development.yaml
weaviate:
url: "http://localhost:8080"
api_key: null
# production.yaml
weaviate:
url: "https://your-cluster.weaviate.network"
api_key: "${WEAVIATE_API_KEY}"
```
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| Vector Database Setup | 3 | 1 |
| Weaviate Schema & Collections | 3 | 1 |
| World Lore Content | 4 | 0 |
| Regional Lore Content | 3 | 0 |
| Embedding Generation | 3 | 1 |
| NPC Knowledge Integration | 4 | 1 |
| DM Lore Access | 2 | 0 |
| **Total** | **22** | **4** |

View File

@@ -0,0 +1,247 @@
# Phase 7: Multiplayer Sessions
**Goal:** Invite-based, time-limited co-op sessions for Premium/Elite players
**Priority:** Low
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
Multiplayer is a paid-tier feature focused on short co-op adventures. Unlike solo story progression, multiplayer sessions are time-limited (2 hours), invite-based, and combat-focused.
**Key Features:**
- Premium/Elite tier only
- Shareable invite links (8-char alphanumeric codes)
- 2-4 player parties
- 2-hour session duration
- AI-generated custom campaigns
- Realtime synchronization via Appwrite
- Character snapshots (doesn't affect solo campaigns)
**Reference:** See `/api/docs/MULTIPLAYER.md` for detailed technical specification.
---
## Task Groups
### Core Infrastructure (7 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.1 | Create MultiplayerSession dataclass | ⬜ | Extends GameSession with time limits, invite codes |
| 7.2 | Create PartyMember dataclass | ⬜ | Player info, character snapshot, ready status |
| 7.3 | Create MultiplayerCampaign models | ⬜ | Campaign, CampaignEncounter, CampaignRewards |
| 7.4 | Implement invite code generation | ⬜ | 8-char alphanumeric, unique, 24hr expiration |
| 7.5 | Implement 2-hour timer logic | ⬜ | Session expiration, warnings (10min, 5min, 1min) |
| 7.6 | Set up Appwrite Realtime subscriptions | ⬜ | WebSocket for live session updates |
| 7.7 | **Checkpoint:** Verify data models | ⬜ | Test serialization and Appwrite storage |
**Deliverable:** Core multiplayer data structures ready
---
### Session Management APIs (5 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.8 | Implement session creation API | ⬜ | `POST /api/v1/multiplayer/create` (Premium/Elite only) |
| 7.9 | Implement join via invite API | ⬜ | `POST /api/v1/multiplayer/join/{invite_code}` |
| 7.10 | Implement lobby system | ⬜ | Ready status, player list, host controls |
| 7.11 | Implement session start API | ⬜ | Host starts when all players ready |
| 7.12 | Implement session end/cleanup | ⬜ | Auto-end at 2 hours, manual end by host |
**API Endpoints:**
```
POST /api/v1/multiplayer/create - Create new session (host)
POST /api/v1/multiplayer/join/{code} - Join via invite code
GET /api/v1/multiplayer/{id}/lobby - Get lobby state
POST /api/v1/multiplayer/{id}/ready - Toggle ready status
POST /api/v1/multiplayer/{id}/start - Start session (host only)
POST /api/v1/multiplayer/{id}/leave - Leave session
POST /api/v1/multiplayer/{id}/end - End session (host only)
```
**Deliverable:** Full session lifecycle management
---
### Campaign Generation (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.13 | Create campaign templates | ⬜ | Pre-built structures for AI to fill |
| 7.14 | Implement AI campaign generator | ⬜ | Generate 3-5 encounters based on party composition |
| 7.15 | Implement encounter sequencing | ⬜ | Linear progression through encounters |
| 7.16 | **Checkpoint:** Test campaign generation | ⬜ | Verify AI creates balanced encounters |
**Campaign Structure:**
```yaml
campaign:
name: "The Cursed Crypt"
description: "A short adventure into an undead-infested tomb"
encounters:
- type: "exploration"
description: "Navigate the tomb entrance"
- type: "combat"
enemies: [skeleton_warrior, skeleton_archer]
- type: "puzzle"
description: "Ancient door mechanism"
- type: "boss"
enemies: [lich_lord]
rewards:
gold_per_player: 500
experience_per_player: 1000
item_pool: ["rare", "epic"]
```
**Deliverable:** AI-generated campaigns for parties
---
### Multiplayer Combat (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.17 | Implement turn management for parties | ⬜ | Initiative, turn order, action validation |
| 7.18 | Extend combat system for multi-player | ⬜ | Reuse Phase 4 combat, add party support |
| 7.19 | Implement disconnect handling | ⬜ | Auto-defend mode, host promotion on disconnect |
| 7.20 | Implement reward distribution | ⬜ | Calculate and grant rewards at session end |
**Turn Order:**
```
1. Roll initiative for all players and enemies
2. Sort by initiative (highest first)
3. Each turn:
- Notify current player via Realtime
- Wait for action (30 second timeout → auto-defend)
- Process action
- Update all clients via Realtime
4. After all enemies defeated → next encounter
```
**Deliverable:** Working party combat system
---
### Multiplayer UI (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.21 | Create lobby UI | ⬜ | `templates/multiplayer/lobby.html` - Player list, ready status, invite link |
| 7.22 | Create active session UI | ⬜ | `templates/multiplayer/session.html` - Timer, party status, combat, narrative |
| 7.23 | Create session complete UI | ⬜ | `templates/multiplayer/complete.html` - Rewards, stats, MVP badges |
| 7.24 | Implement Realtime UI updates | ⬜ | WebSocket subscription for live state |
**Deliverable:** Full multiplayer UI experience
---
### Testing & Validation (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 7.25 | Write unit tests | ⬜ | Invite generation, join validation, timer logic |
| 7.26 | Write integration tests | ⬜ | Full session flow: create → join → play → complete |
| 7.27 | Test realtime synchronization | ⬜ | Multiple browsers simulating party gameplay |
| 7.28 | **Final Checkpoint:** Test session expiration | ⬜ | Force expiration, verify cleanup and reward distribution |
**Deliverable:** Validated multiplayer system
---
## API Endpoints Summary
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/multiplayer/create` | Create new multiplayer session |
| `POST` | `/api/v1/multiplayer/join/{code}` | Join via invite code |
| `GET` | `/api/v1/multiplayer/{id}/lobby` | Get lobby state |
| `POST` | `/api/v1/multiplayer/{id}/ready` | Toggle ready status |
| `POST` | `/api/v1/multiplayer/{id}/start` | Start session (host) |
| `POST` | `/api/v1/multiplayer/{id}/action` | Take action during session |
| `GET` | `/api/v1/multiplayer/{id}/state` | Get current session state |
| `POST` | `/api/v1/multiplayer/{id}/leave` | Leave session |
| `POST` | `/api/v1/multiplayer/{id}/end` | End session (host) |
---
## Files to Create/Modify
**New Files:**
- `/api/app/models/multiplayer.py` - MultiplayerSession, PartyMember, Campaign
- `/api/app/services/multiplayer_service.py` - Session management
- `/api/app/services/campaign_service.py` - AI campaign generation
- `/api/app/api/multiplayer.py` - Multiplayer API blueprint
- `/public_web/templates/multiplayer/lobby.html`
- `/public_web/templates/multiplayer/session.html`
- `/public_web/templates/multiplayer/complete.html`
- `/public_web/static/js/multiplayer-realtime.js` - WebSocket handling
**Modified Files:**
- `/api/app/services/combat_service.py` - Add party combat support
- `/api/app/__init__.py` - Register multiplayer blueprint
---
## Testing Criteria
### Unit Tests
- [ ] Invite code generation (uniqueness, format)
- [ ] Join validation (code exists, not expired, not full)
- [ ] Timer logic (warnings, expiration)
- [ ] Turn order calculation
### Integration Tests
- [ ] Full session flow: create → join → ready → start → play → complete
- [ ] Disconnect handling (player leaves mid-session)
- [ ] Host promotion when original host disconnects
- [ ] Reward distribution at session end
### Manual Testing
- [ ] Multiple browsers simulating party
- [ ] Realtime updates between players
- [ ] Timer warnings display correctly
- [ ] Combat turns cycle correctly
---
## Success Criteria
- [ ] Premium/Elite tier restriction enforced
- [ ] Invite codes work correctly
- [ ] Lobby system functional
- [ ] 2-hour timer with warnings
- [ ] Realtime synchronization working
- [ ] Party combat functional
- [ ] Campaign generation working
- [ ] Rewards distributed at session end
---
## Dependencies
**Requires (already implemented):**
- Combat system (Phase 4)
- Character system
- Session system
- Authentication with tiers
**Requires (from earlier phases):**
- Quest system (optional: party quests)
- Lore system (shared world knowledge)
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| Core Infrastructure | 6 | 1 |
| Session Management APIs | 5 | 0 |
| Campaign Generation | 3 | 1 |
| Multiplayer Combat | 4 | 0 |
| Multiplayer UI | 4 | 0 |
| Testing & Validation | 3 | 1 |
| **Total** | **25** | **3** |

View File

@@ -0,0 +1,206 @@
# Phase 8: Marketplace
**Goal:** Player-to-player trading system (Premium+ only)
**Priority:** Medium
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
The Marketplace enables Premium and Elite players to trade items with each other through auctions and fixed-price listings. This creates a player economy and provides an additional incentive for premium subscriptions.
**Key Features:**
- Premium/Elite tier only
- Auction and fixed-price (buyout) listings
- 48-hour listing duration
- 5% marketplace fee on sales
- Bidding with outbid notifications
- Search and filter functionality
---
## Task Groups
### Marketplace Data Models (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 8.1 | Create MarketplaceListing dataclass | ⬜ | Item, seller, price, auction settings, expiration |
| 8.2 | Create Bid dataclass | ⬜ | Bidder, amount, timestamp |
| 8.3 | **Checkpoint:** Verify serialization | ⬜ | Test round-trip to JSON, Appwrite storage |
**Listing Structure:**
```python
@dataclass
class MarketplaceListing:
listing_id: str
seller_id: str
item: Item
listing_type: str # "auction" | "fixed_price"
starting_price: int
buyout_price: Optional[int]
current_bid: Optional[int]
current_bidder_id: Optional[str]
bids: List[Bid]
created_at: datetime
expires_at: datetime
status: str # "active" | "sold" | "expired" | "cancelled"
```
**Deliverable:** Marketplace data models ready
---
### Marketplace APIs (6 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 8.4 | Implement browse API | ⬜ | `GET /api/v1/marketplace` - Filtering, sorting, pagination |
| 8.5 | Implement listing creation API | ⬜ | `POST /api/v1/marketplace/listings` - Auction + fixed price |
| 8.6 | Implement bidding API | ⬜ | `POST /api/v1/marketplace/listings/{id}/bid` - Validate bid amounts |
| 8.7 | Implement buyout API | ⬜ | `POST /api/v1/marketplace/listings/{id}/buyout` - Instant purchase |
| 8.8 | Implement listing cancellation | ⬜ | `DELETE /api/v1/marketplace/listings/{id}` - Return item to seller |
| 8.9 | Implement my listings/bids API | ⬜ | `GET /api/v1/marketplace/my-listings`, `/my-bids` |
**Deliverable:** Full marketplace API
---
### Auction Processing (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 8.10 | Implement auction processing task | ⬜ | Periodic RQ job to end expired auctions |
| 8.11 | Implement bid notifications | ⬜ | Notify when outbid (in-app notification) |
| 8.12 | Implement sale completion | ⬜ | Transfer item, gold minus 5% fee |
**Auction Processing Flow:**
```
Every 5 minutes:
1. Query listings where expires_at <= now AND status = 'active'
2. For each expired listing:
- If has bids: Award to highest bidder, transfer gold (minus fee)
- If no bids: Return item to seller, mark expired
3. Send notifications to winners/sellers
```
**Deliverable:** Automated auction resolution
---
### Marketplace UI (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 8.13 | Create marketplace browse UI | ⬜ | `templates/marketplace/browse.html` - Grid/list view, filters |
| 8.14 | Create listing detail UI | ⬜ | `templates/marketplace/detail.html` - Item, bids, timer |
| 8.15 | Create listing creation UI | ⬜ | `templates/marketplace/create.html` - Form for creating listings |
| 8.16 | Create my listings/bids UI | ⬜ | `templates/marketplace/my-listings.html` |
**Deliverable:** Full marketplace UI
---
### Testing & Validation (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 8.17 | Test auction flow | ⬜ | Full auction cycle: list → bid → expire → award |
| 8.18 | Test tier restrictions | ⬜ | Verify Premium+ only |
| 8.19 | **Final Checkpoint:** Integration test | ⬜ | Complete buy/sell flow |
**Deliverable:** Validated marketplace system
---
## API Endpoints Summary
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/marketplace` | Browse listings (filter, sort, paginate) |
| `GET` | `/api/v1/marketplace/listings/{id}` | Get listing detail |
| `POST` | `/api/v1/marketplace/listings` | Create new listing |
| `POST` | `/api/v1/marketplace/listings/{id}/bid` | Place bid |
| `POST` | `/api/v1/marketplace/listings/{id}/buyout` | Instant purchase |
| `DELETE` | `/api/v1/marketplace/listings/{id}` | Cancel listing |
| `GET` | `/api/v1/marketplace/my-listings` | Get user's listings |
| `GET` | `/api/v1/marketplace/my-bids` | Get user's active bids |
---
## Files to Create/Modify
**New Files:**
- `/api/app/models/marketplace.py` - MarketplaceListing, Bid
- `/api/app/services/marketplace_service.py` - Marketplace operations
- `/api/app/api/marketplace.py` - Marketplace API blueprint
- `/api/app/tasks/marketplace_tasks.py` - Auction processing job
- `/public_web/templates/marketplace/browse.html`
- `/public_web/templates/marketplace/detail.html`
- `/public_web/templates/marketplace/create.html`
- `/public_web/templates/marketplace/my-listings.html`
**Modified Files:**
- `/api/app/__init__.py` - Register marketplace blueprint
- `/api/app/services/character_service.py` - Gold transfer, item transfer
---
## Testing Criteria
### Unit Tests
- [ ] Listing creation validation
- [ ] Bid validation (must exceed current bid)
- [ ] Buyout validation (sufficient gold)
- [ ] Fee calculation (5%)
### Integration Tests
- [ ] Create listing → item removed from inventory
- [ ] Place bid → gold held in escrow
- [ ] Outbid → gold returned to previous bidder
- [ ] Auction ends → item transferred, gold transferred
- [ ] Cancel listing → item returned
### Manual Testing
- [ ] Browse and filter listings
- [ ] Create auction and fixed-price listings
- [ ] Bid on auctions
- [ ] Buyout items
- [ ] Check notifications when outbid
---
## Success Criteria
- [ ] Premium/Elite tier restriction enforced
- [ ] Listings created and displayed correctly
- [ ] Bidding works with proper validation
- [ ] Buyout works with instant purchase
- [ ] Auctions expire and resolve correctly
- [ ] 5% fee deducted on sales
- [ ] Notifications sent when outbid
- [ ] UI is responsive and functional
---
## Dependencies
**Requires (already implemented):**
- Character system (inventory, gold)
- Item system
- Authentication with tiers
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| Marketplace Data Models | 2 | 1 |
| Marketplace APIs | 6 | 0 |
| Auction Processing | 3 | 0 |
| Marketplace UI | 4 | 0 |
| Testing & Validation | 2 | 1 |
| **Total** | **17** | **2** |

View File

@@ -0,0 +1,219 @@
# Phase 9: Frontend Polish
**Goal:** Improve UI/UX, add HTMX interactivity, create cohesive design system
**Priority:** Medium
**Status:** Not Started
**Last Updated:** November 29, 2025
---
## Overview
Polish the web frontend with a cohesive dark fantasy aesthetic, responsive design, and smooth HTMX-powered interactions. This phase focuses on user experience improvements without adding new features.
**Key Goals:**
- Dark fantasy visual theme
- Mobile-responsive design
- No full page reloads (HTMX everywhere)
- Reusable component library
- Loading states and error handling
- Subtle animations for feedback
---
## Task Groups
### Design System (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.1 | Design CSS color palette | ⬜ | Dark fantasy: deep purples, golds, parchment tones |
| 9.2 | Create typography system | ⬜ | Fantasy-appropriate fonts, readable hierarchy |
| 9.3 | Design component library | ⬜ | Buttons, cards, modals, forms, alerts |
| 9.4 | **Checkpoint:** Review design system | ⬜ | Verify consistency across components |
**Color Palette (Example):**
```css
:root {
/* Backgrounds */
--bg-primary: #1a1a2e; /* Deep navy */
--bg-secondary: #16213e; /* Darker blue */
--bg-card: #0f3460; /* Card background */
/* Accents */
--accent-gold: #c9a227; /* Gold highlights */
--accent-purple: #7b2cbf; /* Magic purple */
--accent-red: #9e2a2b; /* Danger/combat */
/* Text */
--text-primary: #e8e8e8; /* Main text */
--text-secondary: #a0a0a0; /* Muted text */
--text-gold: #ffd700; /* Important text */
}
```
**Deliverable:** Documented design system with CSS variables
---
### Responsive Layout (3 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.5 | Implement mobile navigation | ⬜ | Hamburger menu, slide-out nav |
| 9.6 | Create responsive grid system | ⬜ | Flexbox/Grid layouts that adapt |
| 9.7 | Test on multiple screen sizes | ⬜ | Mobile, tablet, desktop breakpoints |
**Breakpoints:**
```css
/* Mobile first */
@media (min-width: 640px) { /* Tablet */ }
@media (min-width: 1024px) { /* Desktop */ }
@media (min-width: 1280px) { /* Large desktop */ }
```
**Deliverable:** Mobile-friendly responsive layouts
---
### HTMX Enhancements (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.8 | Audit all pages for full page reloads | ⬜ | Identify and fix |
| 9.9 | Implement partial updates everywhere | ⬜ | Use `hx-swap`, `hx-target` correctly |
| 9.10 | Add `hx-indicator` loading states | ⬜ | Show spinners during requests |
| 9.11 | Implement `hx-push-url` for history | ⬜ | Browser back/forward works |
**HTMX Pattern:**
```html
<button hx-post="/api/action"
hx-target="#result-area"
hx-swap="innerHTML"
hx-indicator="#loading-spinner">
Take Action
</button>
<div id="loading-spinner" class="htmx-indicator">
<span class="spinner"></span> Loading...
</div>
```
**Deliverable:** Seamless HTMX-powered interactions
---
### Reusable Components (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.12 | Create character card component | ⬜ | `partials/character_card.html` |
| 9.13 | Create item card component | ⬜ | `partials/item_card.html` - rarity colors |
| 9.14 | Create stat bar component | ⬜ | `partials/stat_bar.html` - HP, mana, XP |
| 9.15 | Create notification/toast component | ⬜ | `partials/toast.html` - success, error, info |
**Deliverable:** Reusable Jinja2 partial templates
---
### Feedback & Polish (4 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.16 | Add loading states for AI calls | ⬜ | Typing indicator, "DM is thinking..." |
| 9.17 | Implement user-friendly error displays | ⬜ | Friendly messages, not stack traces |
| 9.18 | Add dice roll animations | ⬜ | `static/js/dice-roller.js` - visual d20 roll |
| 9.19 | Add combat action animations | ⬜ | Flash effects for damage, healing |
**Deliverable:** Polished user feedback
---
### Cross-Browser Testing (2 tasks)
| Task ID | Task | Status | Notes |
|---------|------|--------|-------|
| 9.20 | Test on Chrome, Firefox, Safari | ⬜ | Fix any browser-specific issues |
| 9.21 | **Final Checkpoint:** UI review | ⬜ | Complete visual audit |
**Deliverable:** Cross-browser compatible UI
---
## Files to Create/Modify
**New Files:**
- `/public_web/static/css/design-system.css` - CSS variables, base styles
- `/public_web/static/css/components.css` - Component styles
- `/public_web/static/js/dice-roller.js` - Dice animation
- `/public_web/static/js/toast.js` - Toast notifications
- `/public_web/templates/partials/character_card.html`
- `/public_web/templates/partials/item_card.html`
- `/public_web/templates/partials/stat_bar.html`
- `/public_web/templates/partials/toast.html`
- `/public_web/templates/partials/loading.html`
**Modified Files:**
- `/public_web/templates/base.html` - Include new CSS/JS, design tokens
- `/public_web/templates/**/*.html` - Apply design system, HTMX patterns
---
## Testing Criteria
### Visual Testing
- [ ] Design system applied consistently
- [ ] Colors and typography match spec
- [ ] Components look correct in all states
### Responsive Testing
- [ ] Mobile layout works (320px - 640px)
- [ ] Tablet layout works (640px - 1024px)
- [ ] Desktop layout works (1024px+)
- [ ] Navigation works on all sizes
### HTMX Testing
- [ ] No full page reloads during gameplay
- [ ] Loading indicators show during requests
- [ ] Browser back/forward works
- [ ] Errors display gracefully
### Browser Testing
- [ ] Chrome (latest)
- [ ] Firefox (latest)
- [ ] Safari (latest)
- [ ] Mobile Safari
- [ ] Chrome Mobile
---
## Success Criteria
- [ ] Cohesive dark fantasy aesthetic
- [ ] Mobile-responsive on all pages
- [ ] No full page reloads in main flows
- [ ] Loading states for all async operations
- [ ] User-friendly error messages
- [ ] Reusable component library
- [ ] Works across major browsers
---
## Dependencies
**Requires (already implemented):**
- All core gameplay features
- HTMX integration (basic)
---
## Task Summary
| Group | Tasks | Checkpoints |
|-------|-------|-------------|
| Design System | 3 | 1 |
| Responsive Layout | 3 | 0 |
| HTMX Enhancements | 4 | 0 |
| Reusable Components | 4 | 0 |
| Feedback & Polish | 4 | 0 |
| Cross-Browser Testing | 1 | 1 |
| **Total** | **19** | **2** |

View File

@@ -145,27 +145,29 @@ def combat_action(session_id: str):
# API returns data directly in result, not nested under 'action_result' # API returns data directly in result, not nested under 'action_result'
log_entries = [] log_entries = []
# Player action entry # The API message is self-contained (includes actor name and damage)
player_entry = { # Don't add separate actor/damage to avoid duplication
'actor': 'You', message = result.get('message', f'Used {action_type}')
'message': result.get('message', f'used {action_type}'),
'type': 'player',
'is_crit': False
}
# Add damage info if present
damage_results = result.get('damage_results', []) damage_results = result.get('damage_results', [])
if damage_results:
for dmg in damage_results:
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
player_entry['is_crit'] = dmg.get('is_critical', False)
if player_entry['is_crit']:
player_entry['type'] = 'crit'
# Add healing info if present # Determine entry type based on damage results
entry_type = 'player'
is_crit = False
if damage_results:
is_crit = any(dmg.get('is_critical', False) for dmg in damage_results)
if is_crit:
entry_type = 'crit'
if result.get('healing'): if result.get('healing'):
player_entry['heal'] = result.get('healing') entry_type = 'heal'
player_entry['type'] = 'heal'
player_entry = {
'actor': '', # API message already includes character name
'message': message,
'type': entry_type,
'is_crit': is_crit
# Don't add 'damage' - it's already in the message
}
log_entries.append(player_entry) log_entries.append(player_entry)
@@ -179,18 +181,32 @@ def combat_action(session_id: str):
'type': 'system' 'type': 'system'
}) })
# Return log entries HTML # Check if it's now enemy's turn
resp = make_response(render_template( next_combatant = result.get('next_combatant_id')
next_is_player = result.get('next_is_player', True)
logger.info("combat_action_result",
next_combatant=next_combatant,
next_is_player=next_is_player,
combat_ended=combat_ended)
# Render log entries
log_html = render_template(
'game/partials/combat_log.html', 'game/partials/combat_log.html',
combat_log=log_entries combat_log=log_entries
)) )
# Trigger enemy turn if it's no longer player's turn # Add script to trigger page refresh after showing the action result
next_combatant = result.get('next_combatant_id') # This is more reliable than headers which can be modified by HTMX
if next_combatant and not result.get('next_is_player', True): refresh_script = '''
resp.headers['HX-Trigger'] = 'enemyTurn' <script>
setTimeout(function() {
window.location.reload();
}, 1200);
</script>
'''
return resp return log_html + refresh_script
except APIError as e: except APIError as e:
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
@@ -233,8 +249,8 @@ def combat_abilities(session_id: str):
ability_response = client.get(f'/api/v1/abilities/{ability_id}') ability_response = client.get(f'/api/v1/abilities/{ability_id}')
ability_data = ability_response.get('result', {}) ability_data = ability_response.get('result', {})
# Check availability # Check availability (API returns 'mana_cost', template uses 'mp_cost')
mp_cost = ability_data.get('mp_cost', 0) mp_cost = ability_data.get('mana_cost', ability_data.get('mp_cost', 0))
cooldown = cooldowns.get(ability_id, 0) cooldown = cooldowns.get(ability_id, 0)
available = current_mp >= mp_cost and cooldown == 0 available = current_mp >= mp_cost and cooldown == 0

View File

@@ -119,7 +119,8 @@ def _build_character_from_api(char_data: dict) -> dict:
'equipped': char_data.get('equipped', {}), 'equipped': char_data.get('equipped', {}),
'inventory': char_data.get('inventory', []), 'inventory': char_data.get('inventory', []),
'gold': char_data.get('gold', 0), 'gold': char_data.get('gold', 0),
'experience': char_data.get('experience', 0) 'experience': char_data.get('experience', 0),
'unlocked_skills': char_data.get('unlocked_skills', [])
} }
@@ -365,20 +366,73 @@ def history_accordion(session_id: str):
@game_bp.route('/session/<session_id>/quests') @game_bp.route('/session/<session_id>/quests')
@require_auth @require_auth
def quests_accordion(session_id: str): def quests_accordion(session_id: str):
"""Refresh quests accordion content.""" """
Refresh quests accordion content.
Fetches full quest data with progress from the character's quest states,
enriching each quest with objective progress information.
"""
client = get_api_client() client = get_api_client()
try: try:
# Get session to access game_state.active_quests # Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}') session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {}) session_data = session_response.get('result', {})
game_state = session_data.get('game_state', {}) character_id = session_data.get('character_id')
quests = game_state.get('active_quests', [])
enriched_quests = []
if character_id:
try:
# Get character's quests with progress
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_quests = quests_data.get('active_quests', [])
# Process each quest to add display-friendly data
for quest in active_quests:
progress_data = quest.get('progress', {})
objectives_progress = progress_data.get('objectives_progress', {})
# Enrich objectives with progress
enriched_objectives = []
all_complete = True
for obj in quest.get('objectives', []):
obj_id = obj.get('objective_id', obj.get('description', ''))
current = objectives_progress.get(obj_id, 0)
# Parse required from progress_text or use default
progress_text = obj.get('progress_text', '0/1')
required = int(progress_text.split('/')[1]) if '/' in progress_text else 1
is_complete = current >= required
if not is_complete:
all_complete = False
enriched_objectives.append({
'description': obj.get('description', ''),
'current': current,
'required': required,
'is_complete': is_complete
})
enriched_quests.append({
'quest_id': quest.get('quest_id', ''),
'name': quest.get('name', 'Unknown Quest'),
'description': quest.get('description', ''),
'difficulty': quest.get('difficulty', 'easy'),
'quest_giver': quest.get('quest_giver_name', ''),
'objectives': enriched_objectives,
'rewards': quest.get('rewards', {}),
'is_complete': all_complete
})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_character_quests", character_id=character_id, error=str(e))
return render_template( return render_template(
'game/partials/sidebar_quests.html', 'game/partials/sidebar_quests.html',
session_id=session_id, session_id=session_id,
quests=quests quests=enriched_quests
) )
except APIError as e: except APIError as e:
logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e)) logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e))
@@ -547,6 +601,7 @@ def poll_job(session_id: str, job_id: str):
nested_result = result.get('result', {}) nested_result = result.get('result', {})
if nested_result.get('context_type') == 'npc_dialogue': if nested_result.get('context_type') == 'npc_dialogue':
# NPC dialogue response - return dialogue partial # NPC dialogue response - return dialogue partial
# Include quest_offer data for inline quest offer card
return render_template( return render_template(
'game/partials/npc_dialogue_response.html', 'game/partials/npc_dialogue_response.html',
npc_name=nested_result.get('npc_name', 'NPC'), npc_name=nested_result.get('npc_name', 'NPC'),
@@ -554,6 +609,8 @@ def poll_job(session_id: str, job_id: str):
conversation_history=nested_result.get('conversation_history', []), conversation_history=nested_result.get('conversation_history', []),
player_line=nested_result.get('player_line', ''), player_line=nested_result.get('player_line', ''),
dialogue=nested_result.get('dialogue', 'No response'), dialogue=nested_result.get('dialogue', 'No response'),
quest_offer=nested_result.get('quest_offer'), # Quest offer data for UI card
npc_id=nested_result.get('npc_id'), # NPC ID for accept/decline
session_id=session_id session_id=session_id
) )
else: else:
@@ -1266,6 +1323,10 @@ def inventory_equip(session_id: str):
if not item_id: if not item_id:
return '<div class="error">No item selected</div>', 400 return '<div class="error">No item selected</div>', 400
if not slot:
logger.warning("equip_missing_slot", item_id=item_id)
return '<div class="error">No equipment slot specified</div>', 400
try: try:
# Get session to find character # Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}') session_response = client.get(f'/api/v1/sessions/{session_id}')
@@ -1276,17 +1337,14 @@ def inventory_equip(session_id: str):
return '<div class="error">No character found</div>', 400 return '<div class="error">No character found</div>', 400
# Equip the item via API # Equip the item via API
payload = {'item_id': item_id} payload = {'item_id': item_id, 'slot': slot}
if slot:
payload['slot'] = slot
client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload) client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload)
# Return updated character panel # Return updated character panel
return redirect(url_for('game.character_panel', session_id=session_id)) return redirect(url_for('game.character_panel', session_id=session_id))
except APIError as e: except APIError as e:
logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e)) logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, slot=slot, error=str(e))
return f'<div class="error">Failed to equip item: {e}</div>', 500 return f'<div class="error">Failed to equip item: {e}</div>', 500
@@ -1373,3 +1431,767 @@ def talk_to_npc(session_id: str, npc_id: str):
except APIError as e: except APIError as e:
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e)) logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500 return f'<div class="error">Failed to talk to NPC: {e}</div>', 500
# ===== Quest Accept/Decline Routes =====
@game_bp.route('/session/<session_id>/quest/accept', methods=['POST'])
@require_auth
def accept_quest(session_id: str):
"""
Accept a quest offer from NPC chat.
Called when player clicks 'Accept Quest' button on inline quest offer card.
Returns updated card with confirmation message and triggers toast notification.
"""
client = get_api_client()
quest_id = request.form.get('quest_id')
npc_id = request.form.get('npc_id')
npc_name = request.form.get('npc_name', 'NPC')
if not quest_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='No quest specified',
session_id=session_id
)
try:
# Get character_id from session
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='Session error - no character found',
session_id=session_id
)
# Call API to accept quest
response = client.post('/api/v1/quests/accept', {
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
result = response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
logger.info(
"quest_accepted",
quest_id=quest_id,
quest_name=quest_name,
character_id=character_id,
session_id=session_id
)
return render_template(
'game/partials/quest_action_response.html',
action='accept',
quest_name=quest_name,
npc_name=npc_name,
session_id=session_id
)
except APIError as e:
logger.error(
"failed_to_accept_quest",
quest_id=quest_id,
session_id=session_id,
error=str(e)
)
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message=str(e),
session_id=session_id
)
@game_bp.route('/session/<session_id>/quest/decline', methods=['POST'])
@require_auth
def decline_quest(session_id: str):
"""
Decline a quest offer from NPC chat.
Called when player clicks 'Decline' button on inline quest offer card.
Returns updated card with decline confirmation.
"""
client = get_api_client()
quest_id = request.form.get('quest_id')
npc_id = request.form.get('npc_id')
npc_name = request.form.get('npc_name', 'NPC')
if not quest_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='No quest specified',
session_id=session_id
)
try:
# Get character_id from session
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message='Session error - no character found',
session_id=session_id
)
# Call API to decline quest
client.post('/api/v1/quests/decline', {
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
logger.info(
"quest_declined",
quest_id=quest_id,
character_id=character_id,
session_id=session_id
)
return render_template(
'game/partials/quest_action_response.html',
action='decline',
quest_name='', # Not needed for decline message
npc_name=npc_name,
session_id=session_id
)
except APIError as e:
logger.error(
"failed_to_decline_quest",
quest_id=quest_id,
session_id=session_id,
error=str(e)
)
return render_template(
'game/partials/quest_action_response.html',
action='error',
error_message=str(e),
session_id=session_id
)
# ===== Shop Routes =====
@game_bp.route('/session/<session_id>/shop-modal')
@require_auth
def shop_modal(session_id: str):
"""
Get shop modal for browsing and purchasing items.
Supports filtering by item type via ?filter= parameter.
Uses the general_store shop.
"""
client = get_api_client()
filter_type = request.args.get('filter', 'all')
message = request.args.get('message', '')
error = request.args.get('error', '')
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
gold = 0
inventory = []
shop = {}
if character_id:
try:
# Get shop inventory with character context (for affordability)
shop_response = client.get(
f'/api/v1/shop/general_store/inventory',
params={'character_id': character_id}
)
shop_data = shop_response.get('result', {})
shop = shop_data.get('shop', {})
inventory = shop_data.get('inventory', [])
# Get character gold
char_data = shop_data.get('character', {})
gold = char_data.get('gold', 0)
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_shop", character_id=character_id, error=str(e))
error = "Failed to load shop inventory"
# Filter inventory by type if specified
if filter_type != 'all':
inventory = [
entry for entry in inventory
if entry.get('item', {}).get('item_type') == filter_type
]
return render_template(
'game/partials/shop_modal.html',
session_id=session_id,
shop=shop,
inventory=inventory,
gold=gold,
filter=filter_type,
message=message,
error=error
)
except APIError as e:
logger.error("failed_to_load_shop_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content shop-modal">
<div class="modal-header">
<h2>Shop</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="shop-empty">Failed to load shop: {e}</div>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/shop/purchase', methods=['POST'])
@require_auth
def shop_purchase(session_id: str):
"""
Purchase an item from the shop.
HTMX endpoint - returns updated shop modal.
"""
client = get_api_client()
item_id = request.form.get('item_id')
quantity = int(request.form.get('quantity', 1))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return shop_modal_with_error(session_id, "No character found for this session")
if not item_id:
return shop_modal_with_error(session_id, "No item specified")
# Attempt purchase
purchase_data = {
'character_id': character_id,
'item_id': item_id,
'quantity': quantity,
'session_id': session_id
}
response = client.post('/api/v1/shop/general_store/purchase', json=purchase_data)
result = response.get('result', {})
# Get item name for message
purchase_info = result.get('purchase', {})
item_name = purchase_info.get('item_id', item_id)
total_cost = purchase_info.get('total_cost', 0)
message = f"Purchased {item_name} for {total_cost} gold!"
logger.info(
"shop_purchase_success",
session_id=session_id,
character_id=character_id,
item_id=item_id,
quantity=quantity,
total_cost=total_cost
)
# Re-render shop modal with success message
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
except APIError as e:
logger.error(
"shop_purchase_failed",
session_id=session_id,
item_id=item_id,
error=str(e)
)
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
@game_bp.route('/session/<session_id>/shop/sell', methods=['POST'])
@require_auth
def shop_sell(session_id: str):
"""
Sell an item to the shop.
HTMX endpoint - returns updated shop modal.
"""
client = get_api_client()
item_instance_id = request.form.get('item_instance_id')
quantity = int(request.form.get('quantity', 1))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return redirect(url_for('game.shop_modal', session_id=session_id, error="No character found"))
if not item_instance_id:
return redirect(url_for('game.shop_modal', session_id=session_id, error="No item specified"))
# Attempt sale
sale_data = {
'character_id': character_id,
'item_instance_id': item_instance_id,
'quantity': quantity,
'session_id': session_id
}
response = client.post('/api/v1/shop/general_store/sell', json=sale_data)
result = response.get('result', {})
sale_info = result.get('sale', {})
item_name = sale_info.get('item_name', 'Item')
total_earned = sale_info.get('total_earned', 0)
message = f"Sold {item_name} for {total_earned} gold!"
logger.info(
"shop_sell_success",
session_id=session_id,
character_id=character_id,
item_instance_id=item_instance_id,
total_earned=total_earned
)
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
except APIError as e:
logger.error(
"shop_sell_failed",
session_id=session_id,
item_instance_id=item_instance_id,
error=str(e)
)
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
def shop_modal_with_error(session_id: str, error: str):
"""Helper to render shop modal with an error message."""
return redirect(url_for('game.shop_modal', session_id=session_id, error=error))
# ===== Quest Routes =====
@game_bp.route('/session/<session_id>/quest/<quest_id>')
@require_auth
def quest_detail(session_id: str, quest_id: str):
"""
Get quest detail modal showing progress and options.
Displays the full quest details with objective progress,
rewards, and options to abandon (if in progress) or
claim rewards (if complete).
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Get quest details
quest_response = client.get(f'/api/v1/quests/{quest_id}')
quest = quest_response.get('result', {})
if not quest:
return _quest_error_modal(f"Quest not found: {quest_id}")
# Get character's quest progress
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_quests = quests_data.get('active_quests', [])
# Find this quest's progress
quest_state = None
objectives_progress = {}
accepted_at = None
for active_quest in active_quests:
if active_quest.get('quest_id') == quest_id:
progress_data = active_quest.get('progress', {})
objectives_progress = progress_data.get('objectives_progress', {})
accepted_at = progress_data.get('accepted_at', '')
break
# Build enriched objectives with progress
enriched_objectives = []
all_complete = True
for obj in quest.get('objectives', []):
obj_id = obj.get('objective_id', '')
progress_text = obj.get('progress_text', '0/1')
required = int(progress_text.split('/')[1]) if '/' in progress_text else 1
current = objectives_progress.get(obj_id, 0)
is_complete = current >= required
if not is_complete:
all_complete = False
enriched_objectives.append({
'objective_id': obj_id,
'description': obj.get('description', ''),
'current_progress': current,
'required_progress': required,
'is_complete': is_complete
})
return render_template(
'game/partials/quest_detail_modal.html',
session_id=session_id,
quest=quest,
objectives=enriched_objectives,
quest_complete=all_complete,
accepted_at=accepted_at
)
except APIError as e:
logger.error("failed_to_load_quest_detail", session_id=session_id, quest_id=quest_id, error=str(e))
return _quest_error_modal(f"Failed to load quest: {e}")
@game_bp.route('/session/<session_id>/quest/offer/<quest_id>')
@require_auth
def quest_offer(session_id: str, quest_id: str):
"""
Display quest offer modal.
Shows quest details when an NPC offers a quest,
with accept/decline options.
"""
client = get_api_client()
npc_id = request.args.get('npc_id', '')
npc_name = request.args.get('npc_name', '')
offer_dialogue = request.args.get('offer_dialogue', '')
try:
# Get session to check character's quest count
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
at_max_quests = False
if character_id:
try:
quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests')
quests_data = quests_response.get('result', {})
active_count = quests_data.get('active_count', 0)
at_max_quests = active_count >= 2
except APIError:
pass
# Get quest details
quest_response = client.get(f'/api/v1/quests/{quest_id}')
quest = quest_response.get('result', {})
if not quest:
return _quest_error_modal(f"Quest not found: {quest_id}")
return render_template(
'game/partials/quest_offer_modal.html',
session_id=session_id,
quest=quest,
npc_id=npc_id,
npc_name=npc_name,
offer_dialogue=offer_dialogue,
at_max_quests=at_max_quests
)
except APIError as e:
logger.error("failed_to_load_quest_offer", session_id=session_id, quest_id=quest_id, error=str(e))
return _quest_error_modal(f"Failed to load quest offer: {e}")
@game_bp.route('/session/<session_id>/quest/accept', methods=['POST'])
@require_auth
def quest_accept(session_id: str):
"""
Accept a quest offer.
Calls the API to add the quest to the character's active quests.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id')
npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Accept the quest
accept_response = client.post('/api/v1/quests/accept', json={
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
result = accept_response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
logger.info(
"quest_accepted",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Return success message that will close modal
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-success-modal">
<div class="modal-header">
<h2>Quest Accepted!</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-success-text">You have accepted: <strong>{quest_name}</strong></p>
<p class="quest-success-hint">Check your Quest Log to track your progress.</p>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">Continue</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_accept_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to accept quest: {error_msg}")
@game_bp.route('/session/<session_id>/quest/decline', methods=['POST'])
@require_auth
def quest_decline(session_id: str):
"""
Decline a quest offer.
Sets a flag to prevent immediate re-offering.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Decline the quest
client.post('/api/v1/quests/decline', json={
'character_id': character_id,
'quest_id': quest_id,
'npc_id': npc_id
})
logger.info(
"quest_declined",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Just close the modal
return ''
except APIError as e:
logger.error("quest_decline_failed", session_id=session_id, quest_id=quest_id, error=str(e))
return '' # Close modal anyway
@game_bp.route('/session/<session_id>/quest/abandon', methods=['POST'])
@require_auth
def quest_abandon(session_id: str):
"""
Abandon an active quest.
Removes the quest from active quests.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Abandon the quest
client.post('/api/v1/quests/abandon', json={
'character_id': character_id,
'quest_id': quest_id
})
logger.info(
"quest_abandoned",
session_id=session_id,
character_id=character_id,
quest_id=quest_id
)
# Return confirmation that will close modal
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-abandon-modal">
<div class="modal-header">
<h2>Quest Abandoned</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p>You have abandoned the quest. You can accept it again later if offered.</p>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">OK</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_abandon_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to abandon quest: {error_msg}")
@game_bp.route('/session/<session_id>/quest/complete', methods=['POST'])
@require_auth
def quest_complete(session_id: str):
"""
Complete a quest and claim rewards.
Grants rewards and moves quest to completed list.
"""
client = get_api_client()
quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id'))
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return _quest_error_modal("No character found for this session")
# Complete the quest
complete_response = client.post('/api/v1/quests/complete', json={
'character_id': character_id,
'quest_id': quest_id
})
result = complete_response.get('result', {})
quest_name = result.get('quest_name', 'Quest')
rewards = result.get('rewards', {})
leveled_up = result.get('leveled_up', False)
new_level = result.get('new_level')
logger.info(
"quest_completed",
session_id=session_id,
character_id=character_id,
quest_id=quest_id,
rewards=rewards
)
# Build rewards display
rewards_html = []
if rewards.get('gold'):
rewards_html.append(f"<li>&#128176; {rewards['gold']} Gold</li>")
if rewards.get('experience'):
rewards_html.append(f"<li>&#9733; {rewards['experience']} XP</li>")
for item in rewards.get('items', []):
rewards_html.append(f"<li>&#127873; {item}</li>")
level_up_html = ""
if leveled_up and new_level:
level_up_html = f'<div class="quest-level-up">Level Up! You are now level {new_level}!</div>'
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-complete-modal">
<div class="modal-header">
<h2>Quest Complete!</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-complete-title">Completed: <strong>{quest_name}</strong></p>
{level_up_html}
<div class="quest-rewards-received">
<h4>Rewards:</h4>
<ul>{"".join(rewards_html)}</ul>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--primary" onclick="closeModal()">Excellent!</button>
</div>
</div>
</div>
'''
except APIError as e:
logger.error("quest_complete_failed", session_id=session_id, quest_id=quest_id, error=str(e))
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
return _quest_error_modal(f"Failed to complete quest: {error_msg}")
def _quest_error_modal(error_message: str) -> str:
"""Helper to render a quest error modal."""
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content quest-error-modal">
<div class="modal-header">
<h2>Error</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="quest-error-text">{error_message}</p>
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
'''

View File

@@ -2139,3 +2139,796 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
} }
/* ===== QUEST MODAL STYLES ===== */
/* Quest Offer Modal */
.quest-offer-modal {
max-width: 550px;
}
.quest-offer-header-info,
.quest-detail-header-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.quest-offer-body,
.quest-detail-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Quest Giver Section */
.quest-offer-giver,
.quest-detail-giver {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
.quest-giver-icon {
font-size: 1.25rem;
}
.quest-giver-name {
font-weight: 600;
color: var(--accent-gold);
}
.quest-giver-says {
color: var(--text-muted);
font-size: var(--text-sm);
}
.quest-giver-label {
color: var(--text-muted);
font-size: var(--text-sm);
}
/* Quest Dialogue */
.quest-offer-dialogue {
padding: 1rem;
background: rgba(243, 156, 18, 0.1);
border-left: 3px solid var(--accent-gold);
border-radius: 0 6px 6px 0;
}
.quest-dialogue-text {
margin: 0;
color: var(--text-secondary);
font-style: italic;
line-height: 1.5;
}
/* Quest Description */
.quest-offer-description,
.quest-detail-description {
color: var(--text-secondary);
line-height: 1.6;
}
.quest-offer-description p,
.quest-detail-description p {
margin: 0;
}
/* Quest Sections */
.quest-offer-section,
.quest-detail-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.quest-section-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
margin: 0;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--play-border);
}
/* Quest Objectives in Modal */
.quest-offer-objectives {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.quest-offer-objective {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-input);
border-radius: 4px;
}
.objective-bullet {
color: var(--accent-gold);
font-size: 1.25rem;
}
.objective-text {
flex: 1;
color: var(--text-secondary);
}
.objective-count {
font-size: var(--text-xs);
color: var(--text-muted);
}
/* Quest Detail Objectives with Progress */
.quest-detail-objectives {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.quest-detail-objective {
padding: 0.75rem;
background: var(--bg-input);
border-radius: 6px;
}
.quest-detail-objective.objective-complete {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.objective-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.objective-check {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--play-border);
border-radius: 50%;
font-size: 12px;
color: var(--text-muted);
}
.objective-complete .objective-check {
background: #10b981;
border-color: #10b981;
color: white;
}
.text-complete,
.text-strikethrough {
text-decoration: line-through;
color: var(--text-muted);
}
/* Progress Bar */
.objective-progress {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-gold), var(--accent-gold-hover));
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-text {
font-size: var(--text-xs);
color: var(--text-muted);
min-width: 40px;
text-align: right;
}
/* Quest Rewards Grid */
.quest-rewards-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.quest-reward-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border-radius: 4px;
}
.reward-icon {
font-size: 1rem;
}
.reward-icon--xp {
color: #10b981;
}
.reward-icon--gold {
color: var(--accent-gold);
}
.reward-icon--item {
color: #8b5cf6;
}
.reward-value {
font-size: var(--text-sm);
color: var(--text-secondary);
}
/* Quest Status Bar */
.quest-status-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 0.5rem;
}
.quest-status-bar--active {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.quest-status-bar--ready {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.status-icon {
font-size: 1.25rem;
}
.quest-status-bar--active .status-icon {
color: #3b82f6;
}
.quest-status-bar--ready .status-icon {
color: #10b981;
}
.status-text {
font-weight: 600;
color: var(--text-primary);
}
.status-hint {
font-size: var(--text-sm);
color: var(--text-muted);
margin-left: auto;
}
/* Quest Warning */
.quest-offer-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
}
.warning-icon {
font-size: 1.25rem;
}
.warning-text {
font-size: var(--text-sm);
}
/* Quest Meta Info */
.quest-detail-meta {
display: flex;
gap: 0.5rem;
font-size: var(--text-xs);
color: var(--text-muted);
padding-top: 0.5rem;
border-top: 1px solid var(--play-border);
}
/* Quest Footer Actions */
.quest-offer-footer,
.quest-detail-footer {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* Quest Success/Complete/Abandon Modals */
.quest-success-modal,
.quest-complete-modal,
.quest-abandon-modal,
.quest-error-modal {
max-width: 400px;
text-align: center;
}
.quest-success-text,
.quest-complete-title {
font-size: var(--text-lg);
margin-bottom: 0.5rem;
}
.quest-success-hint {
color: var(--text-muted);
font-size: var(--text-sm);
}
.quest-level-up {
padding: 0.75rem;
background: rgba(243, 156, 18, 0.2);
border: 1px solid var(--accent-gold);
border-radius: 6px;
color: var(--accent-gold);
font-weight: 600;
margin: 0.75rem 0;
}
.quest-rewards-received {
text-align: left;
padding: 1rem;
background: var(--bg-input);
border-radius: 6px;
}
.quest-rewards-received h4 {
margin: 0 0 0.5rem 0;
font-size: var(--text-sm);
color: var(--text-primary);
}
.quest-rewards-received ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.quest-rewards-received li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.quest-error-text {
color: #ef4444;
}
/* ===== ENHANCED SIDEBAR QUEST STYLES ===== */
/* Clickable Quest Items */
.quest-item {
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.quest-item:hover {
border-color: var(--accent-gold);
transform: translateX(2px);
}
.quest-item:focus {
outline: 2px solid var(--accent-gold);
outline-offset: 2px;
}
.quest-item:active {
transform: translateX(1px);
}
/* Ready to Complete State */
.quest-item--ready {
border: 1px solid rgba(16, 185, 129, 0.5);
background: rgba(16, 185, 129, 0.1);
}
.quest-item--ready:hover {
border-color: #10b981;
}
/* Ready Banner in Sidebar */
.quest-ready-banner {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
background: rgba(16, 185, 129, 0.2);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.ready-icon {
color: #10b981;
font-size: var(--text-sm);
}
.ready-text {
color: #10b981;
font-size: var(--text-xs);
font-weight: 600;
}
/* Overall Quest Progress Bar */
.quest-overall-progress {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--play-border);
}
.mini-progress-bar {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.mini-progress-fill {
height: 100%;
background: var(--accent-gold);
border-radius: 2px;
transition: width 0.3s ease;
}
.mini-progress-text {
font-size: var(--text-xs);
color: var(--text-muted);
}
/* Empty Quest State */
.quest-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
}
.empty-icon {
font-size: 2rem;
opacity: 0.5;
}
.empty-text {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.empty-hint {
margin: 0;
color: var(--text-muted);
font-size: var(--text-xs);
}
/* Difficulty Epic (for future quests) */
.quest-difficulty--epic {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
}
/* Danger Button for Abandon */
.btn--danger {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid #ef4444;
}
.btn--danger:hover {
background: #ef4444;
color: white;
}
/* ===== QUEST OFFER CARD ===== */
/* Inline card that appears in NPC chat when quest is offered */
.quest-offer-card {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.1));
border: 2px solid var(--action-premium);
border-radius: 8px;
margin-top: 1rem;
padding: 1rem;
animation: questCardAppear 0.4s ease;
}
@keyframes questCardAppear {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.quest-offer-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(139, 92, 246, 0.3);
}
.quest-offer-icon {
font-size: 1.25rem;
}
.quest-offer-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
color: var(--action-premium);
font-weight: 600;
}
.quest-offer-content {
margin-bottom: 1rem;
}
.quest-offer-title {
font-family: var(--font-heading);
font-size: var(--text-lg);
color: var(--accent-gold);
margin: 0 0 0.5rem 0;
}
.quest-offer-description {
font-size: var(--text-sm);
color: var(--text-secondary);
margin: 0 0 0.75rem 0;
line-height: 1.5;
}
.quest-offer-rewards {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.rewards-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
}
.reward-item {
font-size: var(--text-sm);
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
}
.reward-gold {
color: var(--accent-gold);
}
.reward-xp {
color: #10b981;
}
.quest-offer-actions {
display: flex;
gap: 0.75rem;
}
.quest-btn {
flex: 1;
padding: 0.625rem 1rem;
font-size: var(--text-sm);
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.quest-btn--accept {
background: #10b981;
color: white;
}
.quest-btn--accept:hover {
background: #059669;
transform: translateY(-1px);
}
.quest-btn--decline {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
border: 1px solid var(--play-border);
}
.quest-btn--decline:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
/* Quest action result (replaces card after action) */
.quest-action-result {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-top: 1rem;
animation: questCardAppear 0.3s ease;
}
.quest-action-result--accept {
background: rgba(16, 185, 129, 0.15);
border: 1px solid #10b981;
}
.quest-action-result--decline {
background: rgba(107, 114, 128, 0.15);
border: 1px solid var(--play-border);
}
.quest-action-result--error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid #ef4444;
}
.quest-action-icon {
font-size: 1.5rem;
}
.quest-action-message {
flex: 1;
}
.quest-action-message strong {
display: block;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.quest-action-message p {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
/* ===== TOAST NOTIFICATIONS ===== */
/* Fixed position notifications for quick feedback */
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 350px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: var(--text-sm);
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
pointer-events: auto;
}
.toast--visible {
opacity: 1;
transform: translateX(0);
}
.toast--dismissing {
opacity: 0;
transform: translateX(100%);
}
.toast--success {
background: rgba(16, 185, 129, 0.95);
color: white;
}
.toast--error {
background: rgba(239, 68, 68, 0.95);
color: white;
}
.toast--info {
background: rgba(59, 130, 246, 0.95);
color: white;
}
.toast-message {
flex: 1;
margin-right: 0.5rem;
}
.toast-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
font-size: 1.25rem;
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s ease;
}
.toast-close:hover {
color: white;
}
/* Mobile responsiveness for quest card */
@media (max-width: 768px) {
.quest-offer-card {
margin-top: 0.75rem;
padding: 0.75rem;
}
.quest-offer-actions {
flex-direction: column;
}
.quest-btn {
width: 100%;
}
.toast-container {
left: 1rem;
right: 1rem;
max-width: none;
}
}

View File

@@ -0,0 +1,404 @@
/**
* Code of Conquest - Shop UI Stylesheet
* Shop modal for browsing and purchasing items
*/
/* ===== SHOP MODAL ===== */
.shop-modal {
max-width: 900px;
width: 95%;
max-height: 85vh;
}
/* ===== SHOP HEADER ===== */
.shop-modal .modal-header {
display: flex;
align-items: center;
gap: 1rem;
}
.shop-header-info {
flex: 1;
}
.shop-header-info .modal-title {
margin: 0;
}
.shop-keeper {
font-size: var(--text-sm, 0.875rem);
color: var(--text-muted, #707078);
font-style: italic;
}
.shop-gold-display {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-tertiary, #16161a);
border: 1px solid var(--accent-gold, #f3a61a);
border-radius: 6px;
}
.shop-gold-display .gold-icon {
font-size: 1.2rem;
}
.shop-gold-display .gold-amount {
font-size: var(--text-lg, 1.125rem);
font-weight: 700;
color: var(--accent-gold, #f3a61a);
}
/* ===== SHOP MESSAGES ===== */
.shop-message {
padding: 0.75rem 1rem;
font-size: var(--text-sm, 0.875rem);
text-align: center;
}
.shop-message--success {
background: rgba(34, 197, 94, 0.15);
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.shop-message--error {
background: rgba(239, 68, 68, 0.15);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
/* ===== SHOP TABS ===== */
.shop-tabs {
display: flex;
gap: 0.25rem;
padding: 0 1rem;
background: var(--bg-tertiary, #16161a);
border-bottom: 1px solid var(--play-border, #3a3a45);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.shop-tabs .tab {
min-height: 48px;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, #a0a0a8);
font-size: var(--text-sm, 0.875rem);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
}
.shop-tabs .tab:hover {
color: var(--text-primary, #e5e5e5);
background: rgba(255, 255, 255, 0.05);
}
.shop-tabs .tab.active {
color: var(--accent-gold, #f3a61a);
border-bottom-color: var(--accent-gold, #f3a61a);
}
/* ===== SHOP BODY ===== */
.shop-body {
padding: 1rem;
overflow-y: auto;
}
/* ===== SHOP GRID ===== */
.shop-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
/* ===== SHOP ITEM CARD ===== */
.shop-item {
display: flex;
flex-direction: column;
padding: 1rem;
background: var(--bg-input, #1e1e24);
border: 2px solid var(--border-primary, #3a3a45);
border-radius: 8px;
transition: all 0.2s ease;
}
.shop-item:hover {
background: rgba(255, 255, 255, 0.03);
transform: translateY(-2px);
}
/* Rarity borders */
.shop-item.rarity-common { border-color: var(--rarity-common, #9ca3af); }
.shop-item.rarity-uncommon { border-color: var(--rarity-uncommon, #22c55e); }
.shop-item.rarity-rare { border-color: var(--rarity-rare, #3b82f6); }
.shop-item.rarity-epic { border-color: var(--rarity-epic, #a855f7); }
.shop-item.rarity-legendary {
border-color: var(--rarity-legendary, #f59e0b);
box-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
}
/* Item Header */
.shop-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.25rem;
}
/* ===== RARITY TAG ===== */
.shop-item-rarity {
display: inline-block;
padding: 0.2rem 0.5rem;
font-size: var(--text-xs, 0.75rem);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.rarity-tag--common {
background: rgba(156, 163, 175, 0.2);
color: var(--rarity-common, #9ca3af);
border: 1px solid var(--rarity-common, #9ca3af);
}
.rarity-tag--uncommon {
background: rgba(34, 197, 94, 0.15);
color: var(--rarity-uncommon, #22c55e);
border: 1px solid var(--rarity-uncommon, #22c55e);
}
.rarity-tag--rare {
background: rgba(59, 130, 246, 0.15);
color: var(--rarity-rare, #3b82f6);
border: 1px solid var(--rarity-rare, #3b82f6);
}
.rarity-tag--epic {
background: rgba(168, 85, 247, 0.15);
color: var(--rarity-epic, #a855f7);
border: 1px solid var(--rarity-epic, #a855f7);
}
.rarity-tag--legendary {
background: rgba(245, 158, 11, 0.15);
color: var(--rarity-legendary, #f59e0b);
border: 1px solid var(--rarity-legendary, #f59e0b);
}
.shop-item-name {
font-family: var(--font-heading, 'Cinzel', serif);
font-size: var(--text-base, 1rem);
font-weight: 600;
color: var(--text-primary, #e5e5e5);
}
.shop-item-type {
font-size: var(--text-xs, 0.75rem);
color: var(--text-muted, #707078);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Item Description */
.shop-item-desc {
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #a0a0a8);
line-height: 1.4;
margin: 0 0 0.75rem 0;
flex: 1;
}
/* Item Stats */
.shop-item-stats {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
min-height: 24px;
}
.shop-item-stats .stat {
font-size: var(--text-xs, 0.75rem);
padding: 0.25rem 0.5rem;
background: var(--bg-tertiary, #16161a);
border-radius: 4px;
color: var(--text-primary, #e5e5e5);
}
/* Item Footer - Price and Buy */
.shop-item-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid var(--play-border, #3a3a45);
}
.shop-item-price {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-base, 1rem);
font-weight: 600;
color: var(--accent-gold, #f3a61a);
}
.shop-item-price .gold-icon {
font-size: 1rem;
}
.shop-item-price.unaffordable {
color: var(--text-muted, #707078);
}
/* ===== PURCHASE BUTTON ===== */
.btn-purchase {
padding: 0.5rem 1rem;
min-width: 90px;
border: none;
border-radius: 6px;
font-size: var(--text-sm, 0.875rem);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-purchase--available {
background: var(--accent-green, #22c55e);
color: white;
}
.btn-purchase--available:hover {
background: #16a34a;
transform: scale(1.02);
}
.btn-purchase--disabled {
background: var(--bg-tertiary, #16161a);
color: var(--text-muted, #707078);
cursor: not-allowed;
}
/* ===== EMPTY STATE ===== */
.shop-empty {
grid-column: 1 / -1;
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted, #707078);
font-style: italic;
}
/* ===== SHOP FOOTER ===== */
.shop-modal .modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.shop-footer-gold {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--accent-gold, #f3a61a);
font-weight: 600;
}
.shop-footer-gold .gold-icon {
font-size: 1.1rem;
}
/* ===== MOBILE RESPONSIVENESS ===== */
@media (max-width: 768px) {
.shop-modal {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
border: none;
}
.shop-modal .modal-header {
flex-wrap: wrap;
gap: 0.5rem;
}
.shop-header-info {
order: 1;
flex: 1 0 60%;
}
.shop-gold-display {
order: 2;
}
.shop-modal .modal-close {
order: 3;
}
.shop-grid {
grid-template-columns: 1fr;
}
.shop-tabs {
padding: 0 0.5rem;
}
.shop-tabs .tab {
min-height: 44px;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.shop-item {
padding: 0.75rem;
}
.shop-item-name {
font-size: var(--text-sm, 0.875rem);
}
}
/* Extra small screens */
@media (max-width: 400px) {
.shop-item-footer {
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
.shop-item-price {
justify-content: center;
}
.btn-purchase {
width: 100%;
}
}
/* ===== ACCESSIBILITY ===== */
.shop-tabs .tab:focus-visible,
.btn-purchase:focus-visible {
outline: 2px solid var(--accent-gold, #f3a61a);
outline-offset: 2px;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.shop-item,
.btn-purchase {
transition: none;
}
.shop-item:hover {
transform: none;
}
}

View File

@@ -0,0 +1,82 @@
/**
* Toast Notification System
* Provides temporary notifications for game events (quest acceptance, errors, etc.)
*
* Usage:
* showToast('Quest accepted!', 'success');
* showToast('Failed to save', 'error');
* showToast('Item added to inventory', 'info');
*/
/**
* Show a toast notification
* @param {string} message - The message to display
* @param {string} type - Type of toast: 'success', 'error', or 'info'
* @param {number} duration - Duration in ms before auto-dismiss (default: 4000)
*/
function showToast(message, type = 'info', duration = 4000) {
// Create toast container if it doesn't exist
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
document.body.appendChild(container);
}
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.innerHTML = `
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close" onclick="dismissToast(this.parentElement)" aria-label="Close">&times;</button>
`;
// Add to container
container.appendChild(toast);
// Trigger animation (small delay for DOM to register element)
requestAnimationFrame(() => {
toast.classList.add('toast--visible');
});
// Auto-remove after duration
setTimeout(() => {
dismissToast(toast);
}, duration);
}
/**
* Dismiss a toast notification with animation
* @param {HTMLElement} toast - The toast element to dismiss
*/
function dismissToast(toast) {
if (!toast || toast.classList.contains('toast--dismissing')) {
return;
}
toast.classList.add('toast--dismissing');
toast.classList.remove('toast--visible');
// Remove from DOM after animation completes
setTimeout(() => {
if (toast.parentElement) {
toast.remove();
}
}, 300);
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Make functions globally available
window.showToast = showToast;
window.dismissToast = dismissToast;

View File

@@ -263,16 +263,23 @@
}, 1000); }, 1000);
} }
// Handle player action triggering enemy turn // Handle player action - refresh page to update UI
document.body.addEventListener('htmx:afterRequest', function(event) { document.body.addEventListener('htmx:afterRequest', function(event) {
const response = event.detail.xhr; const response = event.detail.xhr;
if (!response) return; if (!response) return;
const triggers = response.getResponseHeader('HX-Trigger') || ''; // Check response status - only refresh on success
if (response.status !== 200) return;
// Only trigger enemy turn from player actions (not from our fetch calls) // Check custom header for combat refresh signal
if (triggers.includes('enemyTurn') && !enemyTurnPending) { const shouldRefresh = response.getResponseHeader('X-Combat-Refresh');
triggerEnemyTurn(); console.log('X-Combat-Refresh header:', shouldRefresh);
if (shouldRefresh === 'true') {
// Short delay to let user see their action result
setTimeout(function() {
window.location.reload();
}, 1000);
} }
}); });

View File

@@ -16,8 +16,8 @@
hx-target="#combat-log" hx-target="#combat-log"
hx-swap="beforeend" hx-swap="beforeend"
hx-disabled-elt="this" hx-disabled-elt="this"
{% if not ability.available %}disabled{% endif %} hx-on::before-request="closeModal()"
onclick="closeModal()"> {% if not ability.available %}disabled{% endif %}>
<span class="ability-icon"> <span class="ability-icon">
{% if ability.damage_type == 'fire' %}&#128293; {% if ability.damage_type == 'fire' %}&#128293;
{% elif ability.damage_type == 'ice' %}&#10052; {% elif ability.damage_type == 'ice' %}&#10052;

View File

@@ -103,6 +103,15 @@ Displays character stats, resource bars, and action buttons
⚔️ Equipment & Gear ⚔️ Equipment & Gear
</button> </button>
{# Shop - Opens shop modal #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.shop_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
<span class="action-icon">&#128176;</span>
Shop
</button>
{# Skill Trees - Direct link to skills page #} {# Skill Trees - Direct link to skills page #}
<a class="action-btn action-btn--special" <a class="action-btn action-btn--special"
href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}"> href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}">

View File

@@ -12,14 +12,17 @@ Displays character's equipped gear and inventory summary
{# Modal Body #} {# Modal Body #}
<div class="modal-body"> <div class="modal-body">
{# Equipment Grid #} {# Equipment Grid - All 8 slots matching API #}
<div class="equipment-grid"> <div class="equipment-grid">
{% set slots = [ {% set slots = [
('weapon', 'Weapon'), ('weapon', 'Weapon'),
('armor', 'Armor'), ('off_hand', 'Off-Hand'),
('helmet', 'Helmet'), ('helmet', 'Helmet'),
('chest', 'Chest Armor'),
('gloves', 'Gloves'),
('boots', 'Boots'), ('boots', 'Boots'),
('accessory', 'Accessory') ('accessory_1', 'Accessory 1'),
('accessory_2', 'Accessory 2')
] %} ] %}
{% for slot_id, slot_name in slots %} {% for slot_id, slot_name in slots %}
@@ -34,11 +37,15 @@ Displays character's equipped gear and inventory summary
{# Equipped Item #} {# Equipped Item #}
<div class="slot-item"> <div class="slot-item">
<div class="slot-icon"> <div class="slot-icon">
{% if item.item_type == 'weapon' %}⚔️ {# Icon based on slot_id since item_type doesn't distinguish armor slots #}
{% elif item.item_type == 'armor' %}🛡 {% if slot_id == 'weapon' %}
{% elif item.item_type == 'helmet' %} {% elif slot_id == 'off_hand' %}🛡
{% elif item.item_type == 'boots' %}👢 {% elif slot_id == 'helmet' %}⛑️
{% elif item.item_type == 'accessory' %}💍 {% elif slot_id == 'chest' %}🎽
{% elif slot_id == 'gloves' %}🧤
{% elif slot_id == 'boots' %}👢
{% elif slot_id == 'accessory_1' %}💍
{% elif slot_id == 'accessory_2' %}📿
{% else %}📦{% endif %} {% else %}📦{% endif %}
</div> </div>
<div class="slot-details"> <div class="slot-details">
@@ -63,10 +70,13 @@ Displays character's equipped gear and inventory summary
<div class="slot-empty"> <div class="slot-empty">
<div class="slot-icon slot-icon--empty"> <div class="slot-icon slot-icon--empty">
{% if slot_id == 'weapon' %}⚔️ {% if slot_id == 'weapon' %}⚔️
{% elif slot_id == 'armor' %}🛡️ {% elif slot_id == 'off_hand' %}🛡️
{% elif slot_id == 'helmet' %}⛑️ {% elif slot_id == 'helmet' %}⛑️
{% elif slot_id == 'chest' %}🎽
{% elif slot_id == 'gloves' %}🧤
{% elif slot_id == 'boots' %}👢 {% elif slot_id == 'boots' %}👢
{% elif slot_id == 'accessory' %}💍 {% elif slot_id == 'accessory_1' %}💍
{% elif slot_id == 'accessory_2' %}📿
{% else %}📦{% endif %} {% else %}📦{% endif %}
</div> </div>
<div class="slot-empty-text">Empty</div> <div class="slot-empty-text">Empty</div>

View File

@@ -96,7 +96,7 @@ Partial template loaded via HTMX when an item is selected
hx-vals='{"item_id": "{{ item.item_id }}"{% if suggested_slot %}, "slot": "{{ suggested_slot }}"{% endif %}}' hx-vals='{"item_id": "{{ item.item_id }}"{% if suggested_slot %}, "slot": "{{ suggested_slot }}"{% endif %}}'
hx-target="#character-panel" hx-target="#character-panel"
hx-swap="innerHTML" hx-swap="innerHTML"
onclick="closeModal()"> hx-on::after-request="closeModal()">
Equip Equip
</button> </button>
{% endif %} {% endif %}

View File

@@ -9,6 +9,8 @@ Expected context:
- player_line: What the player just said - player_line: What the player just said
- dialogue: NPC's current response - dialogue: NPC's current response
- session_id: For any follow-up actions - session_id: For any follow-up actions
- quest_offer: Optional quest offer data {quest_id, quest_name, quest_description, rewards, npc_id}
- npc_id: NPC identifier for quest accept/decline
#} #}
{# Only show CURRENT exchange (removed conversation_history loop) #} {# Only show CURRENT exchange (removed conversation_history loop) #}
@@ -23,6 +25,50 @@ Expected context:
</div> </div>
</div> </div>
{# Quest Offer Card - appears inline after NPC dialogue when quest is offered #}
{% if quest_offer %}
<div class="quest-offer-card" id="quest-offer-{{ quest_offer.quest_id }}">
<div class="quest-offer-header">
<span class="quest-offer-icon">&#128220;</span>
<span class="quest-offer-label">Quest Offered</span>
</div>
<div class="quest-offer-content">
<h4 class="quest-offer-title">{{ quest_offer.quest_name }}</h4>
<p class="quest-offer-description">{{ quest_offer.quest_description }}</p>
{% if quest_offer.rewards %}
<div class="quest-offer-rewards">
<span class="rewards-label">Rewards:</span>
{% if quest_offer.rewards.gold %}
<span class="reward-item reward-gold">{{ quest_offer.rewards.gold }} gold</span>
{% endif %}
{% if quest_offer.rewards.experience %}
<span class="reward-item reward-xp">{{ quest_offer.rewards.experience }} XP</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="quest-offer-actions">
<button class="quest-btn quest-btn--accept"
hx-post="{{ url_for('game.accept_quest', session_id=session_id) }}"
hx-vals='{"quest_id": "{{ quest_offer.quest_id }}", "npc_id": "{{ quest_offer.npc_id }}", "npc_name": "{{ npc_name }}"}'
hx-target="#quest-offer-{{ quest_offer.quest_id }}"
hx-swap="outerHTML">
Accept Quest
</button>
<button class="quest-btn quest-btn--decline"
hx-post="{{ url_for('game.decline_quest', session_id=session_id) }}"
hx-vals='{"quest_id": "{{ quest_offer.quest_id }}", "npc_id": "{{ quest_offer.npc_id }}", "npc_name": "{{ npc_name }}"}'
hx-target="#quest-offer-{{ quest_offer.quest_id }}"
hx-swap="outerHTML">
Decline
</button>
</div>
</div>
{% endif %}
{# Trigger history refresh after new message #} {# Trigger history refresh after new message #}
<div hx-trigger="load" <div hx-trigger="load"
_="on load trigger newMessage on body" _="on load trigger newMessage on body"

View File

@@ -0,0 +1,64 @@
{#
Quest Action Response - Replaces quest offer card after accept/decline.
Shows confirmation message and triggers toast notification + sidebar refresh.
Expected context:
- action: 'accept', 'decline', or 'error'
- quest_name: Name of the quest (for accept message)
- npc_name: Name of the NPC offering the quest
- error_message: Error details (for error action)
- session_id: For sidebar refresh
#}
{% if action == 'error' %}
{# Error state - display error message #}
<div class="quest-action-result quest-action-result--error">
<div class="quest-action-icon">&#9888;</div>
<div class="quest-action-message">
<strong>Error</strong>
<p>{{ error_message | default('Something went wrong') }}</p>
</div>
</div>
{% elif action == 'accept' %}
{# Quest accepted - show success message #}
<div class="quest-action-result quest-action-result--accept">
<div class="quest-action-icon">&#10004;</div>
<div class="quest-action-message">
<strong>Quest Accepted!</strong>
<p>{{ npc_name }} nods approvingly. Check your quest log for details.</p>
</div>
</div>
{# Trigger toast notification #}
<script>
if (typeof showToast === 'function') {
showToast('Quest "{{ quest_name }}" accepted!', 'success');
}
</script>
{# Refresh quests accordion to show newly accepted quest #}
<div hx-get="{{ url_for('game.quests_accordion', session_id=session_id) }}"
hx-trigger="load"
hx-target="#accordion-quests"
hx-swap="innerHTML"
style="display: none;"></div>
{% elif action == 'decline' %}
{# Quest declined - show confirmation message #}
<div class="quest-action-result quest-action-result--decline">
<div class="quest-action-icon">&#10006;</div>
<div class="quest-action-message">
<strong>Quest Declined</strong>
<p>{{ npc_name }} understands. Perhaps another time.</p>
</div>
</div>
{# Trigger toast notification #}
<script>
if (typeof showToast === 'function') {
showToast('Quest declined', 'info');
}
</script>
{% endif %}

View File

@@ -0,0 +1,135 @@
{#
Quest Detail Modal
Shows detailed progress on an active quest with abandon option
#}
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
role="dialog" aria-modal="true" aria-labelledby="quest-detail-title">
<div class="modal-content quest-detail-modal">
{# Header #}
<div class="modal-header">
<div class="quest-detail-header-info">
<h2 class="modal-title" id="quest-detail-title">{{ quest.name }}</h2>
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
{{ quest.difficulty|title }}
</span>
</div>
<button class="modal-close" onclick="closeModal()" aria-label="Close">&times;</button>
</div>
{# Status Bar #}
{% if quest_complete %}
<div class="quest-status-bar quest-status-bar--ready">
<span class="status-icon">&#10003;</span>
<span class="status-text">Ready to Complete!</span>
<span class="status-hint">Return to {{ quest.quest_giver_name|default('the quest giver') }} to claim your rewards.</span>
</div>
{% else %}
<div class="quest-status-bar quest-status-bar--active">
<span class="status-icon">&#9881;</span>
<span class="status-text">In Progress</span>
</div>
{% endif %}
{# Body #}
<div class="modal-body quest-detail-body">
{# Quest Giver #}
{% if quest.quest_giver_name %}
<div class="quest-detail-giver">
<span class="quest-giver-label">Quest Giver:</span>
<span class="quest-giver-name">{{ quest.quest_giver_name }}</span>
</div>
{% endif %}
{# Description #}
<div class="quest-detail-description">
<p>{{ quest.description }}</p>
</div>
{# Objectives Section with Progress #}
<div class="quest-detail-section">
<h3 class="quest-section-title">Objectives</h3>
<ul class="quest-detail-objectives">
{% for obj in objectives %}
<li class="quest-detail-objective {% if obj.is_complete %}objective-complete{% endif %}">
<div class="objective-header">
<span class="objective-check">
{% if obj.is_complete %}&#10003;{% else %}&#9675;{% endif %}
</span>
<span class="objective-text {% if obj.is_complete %}text-complete{% endif %}">
{{ obj.description }}
</span>
</div>
{% if obj.required_progress > 1 %}
<div class="objective-progress">
<div class="progress-bar">
<div class="progress-fill"
style="width: {{ (obj.current_progress / obj.required_progress * 100)|int }}%">
</div>
</div>
<span class="progress-text">{{ obj.current_progress }}/{{ obj.required_progress }}</span>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{# Rewards Section #}
<div class="quest-detail-section">
<h3 class="quest-section-title">Rewards</h3>
<div class="quest-rewards-grid">
{% if quest.rewards.experience %}
<div class="quest-reward-item">
<span class="reward-icon reward-icon--xp">&#9733;</span>
<span class="reward-value">{{ quest.rewards.experience }} XP</span>
</div>
{% endif %}
{% if quest.rewards.gold %}
<div class="quest-reward-item">
<span class="reward-icon reward-icon--gold">&#128176;</span>
<span class="reward-value">{{ quest.rewards.gold }} Gold</span>
</div>
{% endif %}
{% if quest.rewards.items %}
{% for item_id in quest.rewards.items %}
<div class="quest-reward-item">
<span class="reward-icon reward-icon--item">&#127873;</span>
<span class="reward-value">{{ item_id|replace('_', ' ')|title }}</span>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{# Accepted Date #}
{% if accepted_at %}
<div class="quest-detail-meta">
<span class="meta-label">Accepted:</span>
<span class="meta-value">{{ accepted_at }}</span>
</div>
{% endif %}
</div>
{# Footer #}
<div class="modal-footer quest-detail-footer">
{% if quest_complete %}
<button class="btn btn--primary"
hx-post="{{ url_for('game.quest_complete', session_id=session_id) }}"
hx-vals='{"quest_id": "{{ quest.quest_id }}"}'
hx-target="#modal-container"
hx-swap="innerHTML"
hx-on::after-request="htmx.trigger(document.body, 'questCompleted')">
Claim Rewards
</button>
{% else %}
<button class="btn btn--danger"
onclick="if(confirm('Are you sure you want to abandon this quest? All progress will be lost.')) { htmx.ajax('POST', '{{ url_for('game.quest_abandon', session_id=session_id) }}', {target: '#modal-container', swap: 'innerHTML', values: {'quest_id': '{{ quest.quest_id }}'}}); htmx.trigger(document.body, 'questAbandoned'); }">
Abandon Quest
</button>
{% endif %}
<button class="btn btn--secondary" onclick="closeModal()">
Close
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,113 @@
{#
Quest Offer Modal
Displays a quest being offered by an NPC with accept/decline options
#}
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
role="dialog" aria-modal="true" aria-labelledby="quest-offer-title">
<div class="modal-content quest-offer-modal">
{# Header #}
<div class="modal-header">
<div class="quest-offer-header-info">
<h2 class="modal-title" id="quest-offer-title">{{ quest.name }}</h2>
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
{{ quest.difficulty|title }}
</span>
</div>
<button class="modal-close" onclick="closeModal()" aria-label="Close">&times;</button>
</div>
{# Body #}
<div class="modal-body quest-offer-body">
{# Quest Giver Section #}
{% if npc_name or quest.quest_giver_name %}
<div class="quest-offer-giver">
<span class="quest-giver-icon">&#128100;</span>
<span class="quest-giver-name">{{ npc_name|default(quest.quest_giver_name) }}</span>
<span class="quest-giver-says">offers you a quest:</span>
</div>
{% endif %}
{# Offer Dialogue / Description #}
{% if offer_dialogue %}
<div class="quest-offer-dialogue">
<p class="quest-dialogue-text">"{{ offer_dialogue }}"</p>
</div>
{% endif %}
<div class="quest-offer-description">
<p>{{ quest.description }}</p>
</div>
{# Objectives Section #}
<div class="quest-offer-section">
<h3 class="quest-section-title">Objectives</h3>
<ul class="quest-offer-objectives">
{% for obj in quest.objectives %}
<li class="quest-offer-objective">
<span class="objective-bullet">&#8226;</span>
<span class="objective-text">{{ obj.description }}</span>
{% if obj.required_progress > 1 %}
<span class="objective-count">(0/{{ obj.required_progress }})</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{# Rewards Section #}
<div class="quest-offer-section">
<h3 class="quest-section-title">Rewards</h3>
<div class="quest-rewards-grid">
{% if quest.rewards.experience %}
<div class="quest-reward-item">
<span class="reward-icon reward-icon--xp">&#9733;</span>
<span class="reward-value">{{ quest.rewards.experience }} XP</span>
</div>
{% endif %}
{% if quest.rewards.gold %}
<div class="quest-reward-item">
<span class="reward-icon reward-icon--gold">&#128176;</span>
<span class="reward-value">{{ quest.rewards.gold }} Gold</span>
</div>
{% endif %}
{% if quest.rewards.items %}
{% for item_id in quest.rewards.items %}
<div class="quest-reward-item">
<span class="reward-icon reward-icon--item">&#127873;</span>
<span class="reward-value">{{ item_id|replace('_', ' ')|title }}</span>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{# Warning if at max quests #}
{% if at_max_quests %}
<div class="quest-offer-warning">
<span class="warning-icon">&#9888;</span>
<span class="warning-text">You already have 2 active quests. Complete or abandon one to accept this quest.</span>
</div>
{% endif %}
</div>
{# Footer #}
<div class="modal-footer quest-offer-footer">
<button class="btn btn--secondary"
hx-post="{{ url_for('game.quest_decline', session_id=session_id) }}"
hx-vals='{"quest_id": "{{ quest.quest_id }}", "npc_id": "{{ npc_id|default('') }}"}'
hx-target="#modal-container"
hx-swap="innerHTML">
Decline
</button>
<button class="btn btn--primary"
{% if at_max_quests %}disabled{% endif %}
hx-post="{{ url_for('game.quest_accept', session_id=session_id) }}"
hx-vals='{"quest_id": "{{ quest.quest_id }}", "npc_id": "{{ npc_id|default('') }}"}'
hx-target="#modal-container"
hx-swap="innerHTML"
hx-on::after-request="htmx.trigger(document.body, 'questAccepted')">
Accept Quest
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,180 @@
{#
Shop Modal
Browse and purchase items from the general store
#}
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
role="dialog" aria-modal="true" aria-labelledby="shop-title">
<div class="modal-content shop-modal">
{# Header #}
<div class="modal-header">
<div class="shop-header-info">
<h2 class="modal-title" id="shop-title">
{{ shop.shop_name|default('General Store') }}
</h2>
<span class="shop-keeper">{{ shop.shopkeeper_name|default('Merchant') }}</span>
</div>
<div class="shop-gold-display">
<span class="gold-icon">&#128176;</span>
<span class="gold-amount">{{ gold }}</span>
</div>
<button class="modal-close" onclick="closeModal()" aria-label="Close shop">&times;</button>
</div>
{# Success/Error Message #}
{% if message %}
<div class="shop-message shop-message--success">
{{ message }}
</div>
{% endif %}
{% if error %}
<div class="shop-message shop-message--error">
{{ error }}
</div>
{% endif %}
{# Tab Filter Bar #}
<div class="shop-tabs" role="tablist">
<button class="tab {% if filter == 'all' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'all' else 'false' }}"
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='all') }}"
hx-target=".shop-modal"
hx-swap="outerHTML">
All
</button>
<button class="tab {% if filter == 'weapon' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'weapon' else 'false' }}"
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='weapon') }}"
hx-target=".shop-modal"
hx-swap="outerHTML">
Weapons
</button>
<button class="tab {% if filter == 'armor' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'armor' else 'false' }}"
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='armor') }}"
hx-target=".shop-modal"
hx-swap="outerHTML">
Armor
</button>
<button class="tab {% if filter == 'consumable' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'consumable' else 'false' }}"
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='consumable') }}"
hx-target=".shop-modal"
hx-swap="outerHTML">
Consumables
</button>
<button class="tab {% if filter == 'accessory' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'accessory' else 'false' }}"
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='accessory') }}"
hx-target=".shop-modal"
hx-swap="outerHTML">
Accessories
</button>
</div>
{# Body - Item Grid #}
<div class="modal-body shop-body">
<div class="shop-grid">
{% for entry in inventory %}
{% set item = entry.item %}
{% set price = entry.shop_price %}
{% set can_afford = entry.can_afford|default(gold >= price) %}
<div class="shop-item rarity-{{ item.rarity|default('common') }}">
{# Item Header #}
<div class="shop-item-header">
<span class="shop-item-name">{{ item.name }}</span>
<span class="shop-item-type">{{ item.item_type|default('item')|replace('_', ' ')|title }}</span>
</div>
{# Rarity Tag #}
<span class="shop-item-rarity rarity-tag--{{ item.rarity|default('common') }}">
{{ item.rarity|default('common')|title }}
</span>
{# Item Description #}
<p class="shop-item-desc">{{ item.description|default('A useful item.')|truncate(80) }}</p>
{# Item Stats #}
<div class="shop-item-stats">
{% if item.item_type == 'weapon' %}
{% if item.damage %}
<span class="stat">Damage: {{ item.damage }}</span>
{% endif %}
{% if item.damage_type %}
<span class="stat">{{ item.damage_type|title }}</span>
{% endif %}
{% elif item.item_type == 'armor' or item.item_type == 'shield' %}
{% if item.defense %}
<span class="stat">Defense: +{{ item.defense }}</span>
{% endif %}
{% if item.slot %}
<span class="stat">{{ item.slot|replace('_', ' ')|title }}</span>
{% endif %}
{% elif item.item_type == 'consumable' %}
{% if item.hp_restore %}
<span class="stat">HP +{{ item.hp_restore }}</span>
{% endif %}
{% if item.mp_restore %}
<span class="stat">MP +{{ item.mp_restore }}</span>
{% endif %}
{% if item.effect %}
<span class="stat">{{ item.effect }}</span>
{% endif %}
{% elif item.item_type == 'accessory' %}
{% if item.stat_bonuses %}
{% for stat, bonus in item.stat_bonuses.items() %}
<span class="stat">{{ stat|title }}: +{{ bonus }}</span>
{% endfor %}
{% endif %}
{% endif %}
</div>
{# Price and Buy Button #}
<div class="shop-item-footer">
<span class="shop-item-price {% if not can_afford %}unaffordable{% endif %}">
<span class="gold-icon">&#128176;</span> {{ price }}
</span>
<button class="btn-purchase {% if can_afford %}btn-purchase--available{% else %}btn-purchase--disabled{% endif %}"
{% if can_afford %}
hx-post="{{ url_for('game.shop_purchase', session_id=session_id) }}"
hx-vals='{"item_id": "{{ item.item_id }}", "quantity": 1}'
hx-target=".shop-modal"
hx-swap="outerHTML"
{% else %}
disabled
{% endif %}
aria-label="{% if can_afford %}Purchase {{ item.name }} for {{ price }} gold{% else %}Not enough gold{% endif %}">
{% if can_afford %}
Buy
{% else %}
Can't Afford
{% endif %}
</button>
</div>
</div>
{% else %}
<p class="shop-empty">
{% if filter == 'all' %}
No items available in this shop.
{% else %}
No {{ filter|replace('_', ' ') }}s available.
{% endif %}
</p>
{% endfor %}
</div>
</div>
{# Footer #}
<div class="modal-footer">
<div class="shop-footer-gold">
<span class="gold-icon">&#128176;</span>
<span class="gold-amount">{{ gold }} gold</span>
</div>
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>

View File

@@ -1,36 +1,83 @@
{# {#
Quests Accordion Content Quests Accordion Content
Shows active quests with objectives and progress Shows active quests with objectives and progress
Enhanced with HTMX for live updates and clickable quest details
#} #}
<div id="quest-list-container"
hx-get="{{ url_for('game.quests_accordion', session_id=session_id) }}"
hx-trigger="questAccepted from:body, questCompleted from:body, questAbandoned from:body, combatEnded from:body"
hx-swap="innerHTML">
{% if quests %} {% if quests %}
<div class="quest-list"> <div class="quest-list">
{% for quest in quests %} {% for quest in quests %}
<div class="quest-item"> <div class="quest-item {% if quest.is_complete %}quest-item--ready{% endif %}"
hx-get="{{ url_for('game.quest_detail', session_id=session_id, quest_id=quest.quest_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
role="button"
tabindex="0"
aria-label="View quest details: {{ quest.name }}">
{# Ready to Complete Banner #}
{% if quest.is_complete %}
<div class="quest-ready-banner">
<span class="ready-icon">&#10003;</span>
<span class="ready-text">Ready to Turn In!</span>
</div>
{% endif %}
<div class="quest-header"> <div class="quest-header">
<span class="quest-name">{{ quest.name }}</span> <span class="quest-name">{{ quest.name }}</span>
<span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}"> <span class="quest-difficulty quest-difficulty--{{ quest.difficulty }}">
{{ quest.difficulty }} {{ quest.difficulty }}
</span> </span>
</div> </div>
{% if quest.quest_giver %}
<div class="quest-giver">From: {{ quest.quest_giver }}</div> <div class="quest-giver">From: {{ quest.quest_giver }}</div>
{% endif %}
<div class="quest-objectives"> <div class="quest-objectives">
{% for objective in quest.objectives %} {% for objective in quest.objectives %}
<div class="quest-objective"> <div class="quest-objective {% if objective.is_complete %}objective-complete{% endif %}">
<span class="quest-objective-check {% if objective.completed %}completed{% endif %}"> <span class="quest-objective-check">
{% if objective.completed %}{% endif %} {% if objective.is_complete %}&#10003;{% else %}&#9675;{% endif %}
</span>
<span class="quest-objective-text {% if objective.is_complete %}text-strikethrough{% endif %}">
{{ objective.description }}
</span> </span>
<span class="quest-objective-text">{{ objective.description }}</span>
{% if objective.required > 1 %} {% if objective.required > 1 %}
<span class="quest-objective-progress">{{ objective.current }}/{{ objective.required }}</span> <span class="quest-objective-progress">
{{ objective.current }}/{{ objective.required }}
</span>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{# Progress Bar for Multi-objective Quests #}
{% if quest.objectives|length > 1 %}
{% set completed_count = quest.objectives|selectattr('is_complete')|list|length %}
{% set total_count = quest.objectives|length %}
<div class="quest-overall-progress">
<div class="mini-progress-bar">
<div class="mini-progress-fill"
style="width: {{ (completed_count / total_count * 100)|int }}%">
</div>
</div>
<span class="mini-progress-text">{{ completed_count }}/{{ total_count }}</span>
</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="quest-empty"> <div class="quest-empty">
No active quests. Talk to NPCs to find adventures! <span class="empty-icon">&#128220;</span>
<p class="empty-text">No active quests.</p>
<p class="empty-hint">Talk to NPCs to find adventures!</p>
</div> </div>
{% endif %} {% endif %}
</div>

View File

@@ -5,6 +5,7 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/shop.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -153,4 +154,7 @@ document.addEventListener('keydown', function(e) {
<!-- Responsive Modal Navigation --> <!-- Responsive Modal Navigation -->
<script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script> <script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script>
<!-- Toast Notifications -->
<script src="{{ url_for('static', filename='js/toast.js') }}"></script>
{% endblock %} {% endblock %}