first commit
This commit is contained in:
171
api/app/__init__.py
Normal file
171
api/app/__init__.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Flask application factory for Code of Conquest.
|
||||
|
||||
Creates and configures the Flask application instance.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from app.config import get_config
|
||||
from app.utils.logging import setup_logging, get_logger
|
||||
|
||||
|
||||
def create_app(environment: str = None) -> Flask:
|
||||
"""
|
||||
Application factory pattern for creating Flask app.
|
||||
|
||||
Args:
|
||||
environment: Environment name (development, production, etc.)
|
||||
If None, uses FLASK_ENV from environment variables.
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance
|
||||
|
||||
Example:
|
||||
>>> app = create_app('development')
|
||||
>>> app.run(debug=True)
|
||||
"""
|
||||
# Get the path to the project root (parent of 'app' package)
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Create Flask app with correct template and static folders
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=os.path.join(project_root, 'templates'),
|
||||
static_folder=os.path.join(project_root, 'static')
|
||||
)
|
||||
|
||||
# Load configuration
|
||||
config = get_config(environment)
|
||||
|
||||
# Configure Flask from config object
|
||||
app.config['SECRET_KEY'] = config.secret_key
|
||||
app.config['DEBUG'] = config.app.debug
|
||||
|
||||
# Set up logging
|
||||
setup_logging(
|
||||
log_level=config.logging.level,
|
||||
log_format=config.logging.format,
|
||||
log_file=config.logging.file_path if 'file' in config.logging.handlers else None
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.info(
|
||||
"Starting Code of Conquest",
|
||||
version=config.app.version,
|
||||
environment=config.app.environment
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
CORS(app, origins=config.cors.origins)
|
||||
|
||||
# Store config in app context
|
||||
app.config['COC_CONFIG'] = config
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Register blueprints (when created)
|
||||
register_blueprints(app)
|
||||
|
||||
logger.info("Application initialized successfully")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask) -> None:
|
||||
"""
|
||||
Register global error handlers for the application.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
from app.utils.response import (
|
||||
error_response,
|
||||
internal_error_response,
|
||||
not_found_response
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_404(error):
|
||||
"""Handle 404 Not Found errors."""
|
||||
logger.warning("404 Not Found", path=error.description)
|
||||
return not_found_response()
|
||||
|
||||
@app.errorhandler(500)
|
||||
def handle_500(error):
|
||||
"""Handle 500 Internal Server errors."""
|
||||
logger.error("500 Internal Server Error", error=str(error), exc_info=True)
|
||||
return internal_error_response()
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
"""Handle uncaught exceptions."""
|
||||
logger.error(
|
||||
"Uncaught exception",
|
||||
error=str(error),
|
||||
error_type=type(error).__name__,
|
||||
exc_info=True
|
||||
)
|
||||
return internal_error_response()
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
"""
|
||||
Register Flask blueprints (API routes and web UI views).
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ===== API Blueprints =====
|
||||
|
||||
# Import and register health check API blueprint
|
||||
from app.api.health import health_bp
|
||||
app.register_blueprint(health_bp)
|
||||
logger.info("Health API blueprint registered")
|
||||
|
||||
# Import and register auth API blueprint
|
||||
from app.api.auth import auth_bp
|
||||
app.register_blueprint(auth_bp)
|
||||
logger.info("Auth API blueprint registered")
|
||||
|
||||
# Import and register characters API blueprint
|
||||
from app.api.characters import characters_bp
|
||||
app.register_blueprint(characters_bp)
|
||||
logger.info("Characters API blueprint registered")
|
||||
|
||||
# Import and register sessions API blueprint
|
||||
from app.api.sessions import sessions_bp
|
||||
app.register_blueprint(sessions_bp)
|
||||
logger.info("Sessions API blueprint registered")
|
||||
|
||||
# Import and register jobs API blueprint
|
||||
from app.api.jobs import jobs_bp
|
||||
app.register_blueprint(jobs_bp)
|
||||
logger.info("Jobs API blueprint registered")
|
||||
|
||||
# Import and register game mechanics API blueprint
|
||||
from app.api.game_mechanics import game_mechanics_bp
|
||||
app.register_blueprint(game_mechanics_bp)
|
||||
logger.info("Game Mechanics API blueprint registered")
|
||||
|
||||
# Import and register travel API blueprint
|
||||
from app.api.travel import travel_bp
|
||||
app.register_blueprint(travel_bp)
|
||||
logger.info("Travel API blueprint registered")
|
||||
|
||||
# Import and register NPCs API blueprint
|
||||
from app.api.npcs import npcs_bp
|
||||
app.register_blueprint(npcs_bp)
|
||||
logger.info("NPCs API blueprint registered")
|
||||
|
||||
# TODO: Register additional blueprints as they are created
|
||||
# from app.api import combat, marketplace, shop
|
||||
# app.register_blueprint(combat.bp, url_prefix='/api/v1/combat')
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
||||
61
api/app/ai/__init__.py
Normal file
61
api/app/ai/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
AI integration module for Code of Conquest.
|
||||
|
||||
This module contains clients and utilities for AI-powered features
|
||||
including narrative generation, quest selection, and NPC dialogue.
|
||||
"""
|
||||
|
||||
from app.ai.replicate_client import (
|
||||
ReplicateClient,
|
||||
ReplicateResponse,
|
||||
ReplicateClientError,
|
||||
ReplicateAPIError,
|
||||
ReplicateRateLimitError,
|
||||
ReplicateTimeoutError,
|
||||
ModelType,
|
||||
)
|
||||
|
||||
from app.ai.model_selector import (
|
||||
ModelSelector,
|
||||
ModelConfig,
|
||||
UserTier,
|
||||
ContextType,
|
||||
)
|
||||
|
||||
from app.ai.prompt_templates import (
|
||||
PromptTemplates,
|
||||
PromptTemplateError,
|
||||
get_prompt_templates,
|
||||
render_prompt,
|
||||
)
|
||||
|
||||
from app.ai.narrative_generator import (
|
||||
NarrativeGenerator,
|
||||
NarrativeResponse,
|
||||
NarrativeGeneratorError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Replicate client
|
||||
"ReplicateClient",
|
||||
"ReplicateResponse",
|
||||
"ReplicateClientError",
|
||||
"ReplicateAPIError",
|
||||
"ReplicateRateLimitError",
|
||||
"ReplicateTimeoutError",
|
||||
"ModelType",
|
||||
# Model selector
|
||||
"ModelSelector",
|
||||
"ModelConfig",
|
||||
"UserTier",
|
||||
"ContextType",
|
||||
# Prompt templates
|
||||
"PromptTemplates",
|
||||
"PromptTemplateError",
|
||||
"get_prompt_templates",
|
||||
"render_prompt",
|
||||
# Narrative generator
|
||||
"NarrativeGenerator",
|
||||
"NarrativeResponse",
|
||||
"NarrativeGeneratorError",
|
||||
]
|
||||
226
api/app/ai/model_selector.py
Normal file
226
api/app/ai/model_selector.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Model selector for tier-based AI model routing.
|
||||
|
||||
This module provides intelligent model selection based on user subscription tiers
|
||||
and context types to optimize cost and quality.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
import structlog
|
||||
|
||||
from app.ai.replicate_client import ModelType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class UserTier(str, Enum):
|
||||
"""User subscription tiers."""
|
||||
FREE = "free"
|
||||
BASIC = "basic"
|
||||
PREMIUM = "premium"
|
||||
ELITE = "elite"
|
||||
|
||||
|
||||
class ContextType(str, Enum):
|
||||
"""Types of AI generation contexts."""
|
||||
STORY_PROGRESSION = "story_progression"
|
||||
COMBAT_NARRATION = "combat_narration"
|
||||
QUEST_SELECTION = "quest_selection"
|
||||
NPC_DIALOGUE = "npc_dialogue"
|
||||
SIMPLE_RESPONSE = "simple_response"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
"""Configuration for a selected model."""
|
||||
model_type: ModelType
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Get the model identifier string."""
|
||||
return self.model_type.value
|
||||
|
||||
|
||||
class ModelSelector:
|
||||
"""
|
||||
Selects appropriate AI models based on user tier and context.
|
||||
|
||||
This class implements tier-based routing to ensure:
|
||||
- Free users get Llama-3 (no cost)
|
||||
- Basic users get Claude Haiku (low cost)
|
||||
- Premium users get Claude Sonnet (medium cost)
|
||||
- Elite users get Claude Opus (high cost)
|
||||
|
||||
Context-specific optimizations adjust token limits and temperature
|
||||
for different use cases.
|
||||
"""
|
||||
|
||||
# Tier to model mapping
|
||||
TIER_MODELS = {
|
||||
UserTier.FREE: ModelType.LLAMA_3_8B,
|
||||
UserTier.BASIC: ModelType.CLAUDE_HAIKU,
|
||||
UserTier.PREMIUM: ModelType.CLAUDE_SONNET,
|
||||
UserTier.ELITE: ModelType.CLAUDE_SONNET_4,
|
||||
}
|
||||
|
||||
# Base token limits by tier
|
||||
BASE_TOKEN_LIMITS = {
|
||||
UserTier.FREE: 256,
|
||||
UserTier.BASIC: 512,
|
||||
UserTier.PREMIUM: 1024,
|
||||
UserTier.ELITE: 2048,
|
||||
}
|
||||
|
||||
# Temperature settings by context type
|
||||
CONTEXT_TEMPERATURES = {
|
||||
ContextType.STORY_PROGRESSION: 0.9, # Creative, varied
|
||||
ContextType.COMBAT_NARRATION: 0.8, # Exciting but coherent
|
||||
ContextType.QUEST_SELECTION: 0.5, # More deterministic
|
||||
ContextType.NPC_DIALOGUE: 0.85, # Natural conversation
|
||||
ContextType.SIMPLE_RESPONSE: 0.7, # Balanced
|
||||
}
|
||||
|
||||
# Token multipliers by context (relative to base)
|
||||
CONTEXT_TOKEN_MULTIPLIERS = {
|
||||
ContextType.STORY_PROGRESSION: 1.0, # Full allocation
|
||||
ContextType.COMBAT_NARRATION: 0.75, # Shorter, punchier
|
||||
ContextType.QUEST_SELECTION: 0.5, # Brief selection
|
||||
ContextType.NPC_DIALOGUE: 0.75, # Conversational
|
||||
ContextType.SIMPLE_RESPONSE: 0.5, # Quick responses
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the model selector."""
|
||||
logger.info("ModelSelector initialized")
|
||||
|
||||
def select_model(
|
||||
self,
|
||||
user_tier: UserTier,
|
||||
context_type: ContextType = ContextType.SIMPLE_RESPONSE
|
||||
) -> ModelConfig:
|
||||
"""
|
||||
Select the appropriate model configuration for a user and context.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
context_type: The type of content being generated.
|
||||
|
||||
Returns:
|
||||
ModelConfig with model type, token limit, and temperature.
|
||||
|
||||
Example:
|
||||
>>> selector = ModelSelector()
|
||||
>>> config = selector.select_model(UserTier.PREMIUM, ContextType.STORY_PROGRESSION)
|
||||
>>> config.model_type
|
||||
<ModelType.CLAUDE_SONNET: 'anthropic/claude-3.5-sonnet'>
|
||||
"""
|
||||
# Get model for tier
|
||||
model_type = self.TIER_MODELS[user_tier]
|
||||
|
||||
# Calculate max tokens
|
||||
base_tokens = self.BASE_TOKEN_LIMITS[user_tier]
|
||||
multiplier = self.CONTEXT_TOKEN_MULTIPLIERS.get(context_type, 1.0)
|
||||
max_tokens = int(base_tokens * multiplier)
|
||||
|
||||
# Get temperature for context
|
||||
temperature = self.CONTEXT_TEMPERATURES.get(context_type, 0.7)
|
||||
|
||||
config = ModelConfig(
|
||||
model_type=model_type,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Model selected",
|
||||
user_tier=user_tier.value,
|
||||
context_type=context_type.value,
|
||||
model=model_type.value,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
def get_model_for_tier(self, user_tier: UserTier) -> ModelType:
|
||||
"""
|
||||
Get the default model for a user tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
|
||||
Returns:
|
||||
The ModelType for this tier.
|
||||
"""
|
||||
return self.TIER_MODELS[user_tier]
|
||||
|
||||
def get_tier_info(self, user_tier: UserTier) -> dict:
|
||||
"""
|
||||
Get information about a tier's AI capabilities.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
|
||||
Returns:
|
||||
Dictionary with tier information.
|
||||
"""
|
||||
model_type = self.TIER_MODELS[user_tier]
|
||||
|
||||
# Map models to friendly names
|
||||
model_names = {
|
||||
ModelType.LLAMA_3_8B: "Llama 3 8B",
|
||||
ModelType.CLAUDE_HAIKU: "Claude 3 Haiku",
|
||||
ModelType.CLAUDE_SONNET: "Claude 3.5 Sonnet",
|
||||
ModelType.CLAUDE_SONNET_4: "Claude Sonnet 4",
|
||||
}
|
||||
|
||||
# Model quality descriptions
|
||||
quality_descriptions = {
|
||||
ModelType.LLAMA_3_8B: "Good quality, optimized for speed",
|
||||
ModelType.CLAUDE_HAIKU: "High quality, fast responses",
|
||||
ModelType.CLAUDE_SONNET: "Excellent quality, detailed narratives",
|
||||
ModelType.CLAUDE_SONNET_4: "Best quality, most creative and nuanced",
|
||||
}
|
||||
|
||||
return {
|
||||
"tier": user_tier.value,
|
||||
"model": model_type.value,
|
||||
"model_name": model_names.get(model_type, model_type.value),
|
||||
"base_tokens": self.BASE_TOKEN_LIMITS[user_tier],
|
||||
"quality": quality_descriptions.get(model_type, "Standard quality"),
|
||||
}
|
||||
|
||||
def estimate_cost_per_request(self, user_tier: UserTier) -> float:
|
||||
"""
|
||||
Estimate the cost per AI request for a tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier.
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD per request.
|
||||
|
||||
Note:
|
||||
These are rough estimates based on typical usage.
|
||||
Actual costs depend on input/output tokens.
|
||||
"""
|
||||
# Approximate cost per 1K tokens (input + output average)
|
||||
COST_PER_1K_TOKENS = {
|
||||
ModelType.LLAMA_3_8B: 0.0, # Free tier
|
||||
ModelType.CLAUDE_HAIKU: 0.001, # $0.25/1M input, $1.25/1M output
|
||||
ModelType.CLAUDE_SONNET: 0.006, # $3/1M input, $15/1M output
|
||||
ModelType.CLAUDE_SONNET_4: 0.015, # Claude Sonnet 4 pricing
|
||||
}
|
||||
|
||||
model_type = self.TIER_MODELS[user_tier]
|
||||
base_tokens = self.BASE_TOKEN_LIMITS[user_tier]
|
||||
cost_per_1k = COST_PER_1K_TOKENS.get(model_type, 0.0)
|
||||
|
||||
# Estimate: base tokens for output + ~50% for input tokens
|
||||
estimated_tokens = base_tokens * 1.5
|
||||
|
||||
return (estimated_tokens / 1000) * cost_per_1k
|
||||
540
api/app/ai/narrative_generator.py
Normal file
540
api/app/ai/narrative_generator.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
Narrative generator wrapper for AI content generation.
|
||||
|
||||
This module provides a high-level API for generating narrative content
|
||||
using the appropriate AI models based on user tier and context.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.ai.replicate_client import (
|
||||
ReplicateClient,
|
||||
ReplicateResponse,
|
||||
ReplicateClientError,
|
||||
)
|
||||
from app.ai.model_selector import (
|
||||
ModelSelector,
|
||||
ModelConfig,
|
||||
UserTier,
|
||||
ContextType,
|
||||
)
|
||||
from app.ai.prompt_templates import (
|
||||
PromptTemplates,
|
||||
PromptTemplateError,
|
||||
get_prompt_templates,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NarrativeResponse:
|
||||
"""Response from narrative generation."""
|
||||
narrative: str
|
||||
tokens_used: int
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
model: str
|
||||
context_type: str
|
||||
generation_time: float
|
||||
|
||||
|
||||
class NarrativeGeneratorError(Exception):
|
||||
"""Base exception for narrative generator errors."""
|
||||
pass
|
||||
|
||||
|
||||
class NarrativeGenerator:
|
||||
"""
|
||||
High-level wrapper for AI narrative generation.
|
||||
|
||||
This class coordinates between the model selector, prompt templates,
|
||||
and AI clients to generate narrative content for the game.
|
||||
|
||||
It provides specialized methods for different narrative contexts:
|
||||
- Story progression responses
|
||||
- Combat narration
|
||||
- Quest selection
|
||||
- NPC dialogue
|
||||
"""
|
||||
|
||||
# System prompts for different contexts
|
||||
SYSTEM_PROMPTS = {
|
||||
ContextType.STORY_PROGRESSION: (
|
||||
"You are an expert Dungeon Master running a solo D&D-style adventure. "
|
||||
"Create immersive, engaging narratives that respond to player actions. "
|
||||
"Be descriptive but concise. Always end with a clear opportunity for the player to act. "
|
||||
"CRITICAL: NEVER give the player items, gold, equipment, or any rewards unless the action "
|
||||
"instructions explicitly state they should receive them. Only narrate what the template "
|
||||
"describes - do not improvise rewards or discoveries."
|
||||
),
|
||||
ContextType.COMBAT_NARRATION: (
|
||||
"You are a combat narrator for a fantasy RPG. "
|
||||
"Describe actions with visceral, cinematic detail. "
|
||||
"Keep narration punchy and exciting. Never include game mechanics in prose."
|
||||
),
|
||||
ContextType.QUEST_SELECTION: (
|
||||
"You are a quest selection system. "
|
||||
"Analyze the context and select the most narratively appropriate quest. "
|
||||
"Respond only with the quest_id - no explanation."
|
||||
),
|
||||
ContextType.NPC_DIALOGUE: (
|
||||
"You are a skilled voice actor portraying NPCs in a fantasy world. "
|
||||
"Stay in character at all times. Give each NPC a distinct voice and personality. "
|
||||
"Provide useful information while maintaining immersion."
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_selector: ModelSelector | None = None,
|
||||
replicate_client: ReplicateClient | None = None,
|
||||
prompt_templates: PromptTemplates | None = None
|
||||
):
|
||||
"""
|
||||
Initialize the narrative generator.
|
||||
|
||||
Args:
|
||||
model_selector: Optional custom model selector.
|
||||
replicate_client: Optional custom Replicate client.
|
||||
prompt_templates: Optional custom prompt templates.
|
||||
"""
|
||||
self.model_selector = model_selector or ModelSelector()
|
||||
self.replicate_client = replicate_client
|
||||
self.prompt_templates = prompt_templates or get_prompt_templates()
|
||||
|
||||
logger.info("NarrativeGenerator initialized")
|
||||
|
||||
def _get_client(self, model_config: ModelConfig) -> ReplicateClient:
|
||||
"""
|
||||
Get or create a Replicate client for the given model configuration.
|
||||
|
||||
Args:
|
||||
model_config: The model configuration to use.
|
||||
|
||||
Returns:
|
||||
ReplicateClient configured for the specified model.
|
||||
"""
|
||||
# If a client was provided at init, use it
|
||||
if self.replicate_client:
|
||||
return self.replicate_client
|
||||
|
||||
# Otherwise create a new client with the specified model
|
||||
return ReplicateClient(model=model_config.model_type)
|
||||
|
||||
def generate_story_response(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
action: str,
|
||||
game_state: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
conversation_history: list[dict[str, Any]] | None = None,
|
||||
world_context: str | None = None,
|
||||
action_instructions: str | None = None
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate a DM response to a player's story action.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary with name, level, player_class, stats, etc.
|
||||
action: The action the player wants to take.
|
||||
game_state: Current game state with location, quests, etc.
|
||||
user_tier: The user's subscription tier.
|
||||
conversation_history: Optional list of recent conversation entries.
|
||||
world_context: Optional additional world information.
|
||||
action_instructions: Optional action-specific instructions for the AI from
|
||||
the dm_prompt_template field in action_prompts.yaml.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with the generated narrative and metadata.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails.
|
||||
|
||||
Example:
|
||||
>>> generator = NarrativeGenerator()
|
||||
>>> response = generator.generate_story_response(
|
||||
... character={"name": "Aldric", "level": 3, "player_class": "Fighter", ...},
|
||||
... action="I search the room for hidden doors",
|
||||
... game_state={"current_location": "Ancient Library", ...},
|
||||
... user_tier=UserTier.PREMIUM
|
||||
... )
|
||||
>>> print(response.narrative)
|
||||
"""
|
||||
context_type = ContextType.STORY_PROGRESSION
|
||||
|
||||
logger.info(
|
||||
"Generating story response",
|
||||
character_name=character.get("name"),
|
||||
action=action[:50],
|
||||
user_tier=user_tier.value,
|
||||
location=game_state.get("current_location")
|
||||
)
|
||||
|
||||
# Get model configuration for this tier and context
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt from template
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"story_action.j2",
|
||||
character=character,
|
||||
action=action,
|
||||
game_state=game_state,
|
||||
conversation_history=conversation_history or [],
|
||||
world_context=world_context,
|
||||
max_tokens=model_config.max_tokens,
|
||||
action_instructions=action_instructions
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render story prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Debug: Log the full prompt being sent
|
||||
logger.debug(
|
||||
"Full prompt being sent to AI",
|
||||
prompt_length=len(prompt),
|
||||
conversation_history_count=len(conversation_history) if conversation_history else 0,
|
||||
prompt_preview=prompt[:500] + "..." if len(prompt) > 500 else prompt
|
||||
)
|
||||
# For detailed debugging, uncomment the line below:
|
||||
print(f"\n{'='*60}\nFULL PROMPT:\n{'='*60}\n{prompt}\n{'='*60}\n")
|
||||
|
||||
# Get system prompt
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
# Generate response
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error(
|
||||
"AI generation failed",
|
||||
error=str(e),
|
||||
context_type=context_type.value
|
||||
)
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"Story response generated",
|
||||
tokens_used=response.tokens_used,
|
||||
model=response.model,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return NarrativeResponse(
|
||||
narrative=response.text,
|
||||
tokens_used=response.tokens_used,
|
||||
tokens_input=response.tokens_input,
|
||||
tokens_output=response.tokens_output,
|
||||
model=response.model,
|
||||
context_type=context_type.value,
|
||||
generation_time=response.generation_time
|
||||
)
|
||||
|
||||
def generate_combat_narration(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
combat_state: dict[str, Any],
|
||||
action: str,
|
||||
action_result: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
is_critical: bool = False,
|
||||
is_finishing_blow: bool = False
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate narration for a combat action.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary.
|
||||
combat_state: Current combat state with enemies, round number, etc.
|
||||
action: Description of the combat action taken.
|
||||
action_result: Result of the action (hit, damage, effects, etc.).
|
||||
user_tier: The user's subscription tier.
|
||||
is_critical: Whether this was a critical hit/miss.
|
||||
is_finishing_blow: Whether this defeats the enemy.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with combat narration.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails.
|
||||
|
||||
Example:
|
||||
>>> response = generator.generate_combat_narration(
|
||||
... character={"name": "Aldric", ...},
|
||||
... combat_state={"round_number": 3, "enemies": [...], ...},
|
||||
... action="swings their sword at the goblin",
|
||||
... action_result={"hit": True, "damage": 12, ...},
|
||||
... user_tier=UserTier.BASIC
|
||||
... )
|
||||
"""
|
||||
context_type = ContextType.COMBAT_NARRATION
|
||||
|
||||
logger.info(
|
||||
"Generating combat narration",
|
||||
character_name=character.get("name"),
|
||||
action=action[:50],
|
||||
is_critical=is_critical,
|
||||
is_finishing_blow=is_finishing_blow
|
||||
)
|
||||
|
||||
# Get model configuration
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"combat_action.j2",
|
||||
character=character,
|
||||
combat_state=combat_state,
|
||||
action=action,
|
||||
action_result=action_result,
|
||||
is_critical=is_critical,
|
||||
is_finishing_blow=is_finishing_blow,
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render combat prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Generate response
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error("Combat narration generation failed", error=str(e))
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"Combat narration generated",
|
||||
tokens_used=response.tokens_used,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return NarrativeResponse(
|
||||
narrative=response.text,
|
||||
tokens_used=response.tokens_used,
|
||||
tokens_input=response.tokens_input,
|
||||
tokens_output=response.tokens_output,
|
||||
model=response.model,
|
||||
context_type=context_type.value,
|
||||
generation_time=response.generation_time
|
||||
)
|
||||
|
||||
def generate_quest_selection(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
eligible_quests: list[dict[str, Any]],
|
||||
game_context: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
recent_actions: list[str] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Use AI to select the most contextually appropriate quest.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary.
|
||||
eligible_quests: List of quest data dictionaries that can be offered.
|
||||
game_context: Current game context (location, events, etc.).
|
||||
user_tier: The user's subscription tier.
|
||||
recent_actions: Optional list of recent player actions.
|
||||
|
||||
Returns:
|
||||
The quest_id of the selected quest.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails or response is invalid.
|
||||
|
||||
Example:
|
||||
>>> quest_id = generator.generate_quest_selection(
|
||||
... character={"name": "Aldric", "level": 3, ...},
|
||||
... eligible_quests=[{"quest_id": "goblin_cave", ...}, ...],
|
||||
... game_context={"current_location": "Tavern", ...},
|
||||
... user_tier=UserTier.FREE
|
||||
... )
|
||||
>>> print(quest_id) # "goblin_cave"
|
||||
"""
|
||||
context_type = ContextType.QUEST_SELECTION
|
||||
|
||||
logger.info(
|
||||
"Generating quest selection",
|
||||
character_name=character.get("name"),
|
||||
num_eligible_quests=len(eligible_quests),
|
||||
location=game_context.get("current_location")
|
||||
)
|
||||
|
||||
if not eligible_quests:
|
||||
raise NarrativeGeneratorError("No eligible quests provided")
|
||||
|
||||
# Get model configuration
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"quest_offering.j2",
|
||||
character=character,
|
||||
eligible_quests=eligible_quests,
|
||||
game_context=game_context,
|
||||
recent_actions=recent_actions or []
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render quest selection prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Generate response
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error("Quest selection generation failed", error=str(e))
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
# Parse the response to get quest_id
|
||||
quest_id = response.text.strip().lower()
|
||||
|
||||
# Validate the response is a valid quest_id
|
||||
valid_quest_ids = {q.get("quest_id", "").lower() for q in eligible_quests}
|
||||
if quest_id not in valid_quest_ids:
|
||||
logger.warning(
|
||||
"AI returned invalid quest_id, using first eligible quest",
|
||||
returned_id=quest_id,
|
||||
valid_ids=list(valid_quest_ids)
|
||||
)
|
||||
quest_id = eligible_quests[0].get("quest_id", "")
|
||||
|
||||
logger.info(
|
||||
"Quest selected",
|
||||
quest_id=quest_id,
|
||||
tokens_used=response.tokens_used,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return quest_id
|
||||
|
||||
def generate_npc_dialogue(
|
||||
self,
|
||||
character: dict[str, Any],
|
||||
npc: dict[str, Any],
|
||||
conversation_topic: str,
|
||||
game_state: dict[str, Any],
|
||||
user_tier: UserTier,
|
||||
npc_relationship: str | None = None,
|
||||
previous_dialogue: list[dict[str, Any]] | None = None,
|
||||
npc_knowledge: list[str] | None = None
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate NPC dialogue in response to player conversation.
|
||||
|
||||
Args:
|
||||
character: Character data dictionary.
|
||||
npc: NPC data with name, role, personality, etc.
|
||||
conversation_topic: What the player said or wants to discuss.
|
||||
game_state: Current game state.
|
||||
user_tier: The user's subscription tier.
|
||||
npc_relationship: Optional description of relationship with NPC.
|
||||
previous_dialogue: Optional list of previous exchanges.
|
||||
npc_knowledge: Optional list of things this NPC knows about.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with NPC dialogue.
|
||||
|
||||
Raises:
|
||||
NarrativeGeneratorError: If generation fails.
|
||||
|
||||
Example:
|
||||
>>> response = generator.generate_npc_dialogue(
|
||||
... character={"name": "Aldric", ...},
|
||||
... npc={"name": "Old Barkeep", "role": "Tavern Owner", ...},
|
||||
... conversation_topic="What rumors have you heard lately?",
|
||||
... game_state={"current_location": "The Rusty Anchor", ...},
|
||||
... user_tier=UserTier.PREMIUM
|
||||
... )
|
||||
"""
|
||||
context_type = ContextType.NPC_DIALOGUE
|
||||
|
||||
logger.info(
|
||||
"Generating NPC dialogue",
|
||||
character_name=character.get("name"),
|
||||
npc_name=npc.get("name"),
|
||||
topic=conversation_topic[:50]
|
||||
)
|
||||
|
||||
# Get model configuration
|
||||
model_config = self.model_selector.select_model(user_tier, context_type)
|
||||
|
||||
# Build the prompt
|
||||
try:
|
||||
prompt = self.prompt_templates.render(
|
||||
"npc_dialogue.j2",
|
||||
character=character,
|
||||
npc=npc,
|
||||
conversation_topic=conversation_topic,
|
||||
game_state=game_state,
|
||||
npc_relationship=npc_relationship,
|
||||
previous_dialogue=previous_dialogue or [],
|
||||
npc_knowledge=npc_knowledge or [],
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
logger.error("Failed to render NPC dialogue prompt", error=str(e))
|
||||
raise NarrativeGeneratorError(f"Prompt template error: {e}")
|
||||
|
||||
# Generate response
|
||||
system_prompt = self.SYSTEM_PROMPTS[context_type]
|
||||
|
||||
try:
|
||||
client = self._get_client(model_config)
|
||||
response = client.generate(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
max_tokens=model_config.max_tokens,
|
||||
temperature=model_config.temperature,
|
||||
model=model_config.model_type
|
||||
)
|
||||
except ReplicateClientError as e:
|
||||
logger.error("NPC dialogue generation failed", error=str(e))
|
||||
raise NarrativeGeneratorError(f"AI generation failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"NPC dialogue generated",
|
||||
npc_name=npc.get("name"),
|
||||
tokens_used=response.tokens_used,
|
||||
generation_time=f"{response.generation_time:.2f}s"
|
||||
)
|
||||
|
||||
return NarrativeResponse(
|
||||
narrative=response.text,
|
||||
tokens_used=response.tokens_used,
|
||||
tokens_input=response.tokens_input,
|
||||
tokens_output=response.tokens_output,
|
||||
model=response.model,
|
||||
context_type=context_type.value,
|
||||
generation_time=response.generation_time
|
||||
)
|
||||
318
api/app/ai/prompt_templates.py
Normal file
318
api/app/ai/prompt_templates.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Jinja2 prompt template system for AI generation.
|
||||
|
||||
This module provides a templating system for building AI prompts
|
||||
with consistent structure and context injection.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PromptTemplateError(Exception):
|
||||
"""Error in prompt template processing."""
|
||||
pass
|
||||
|
||||
|
||||
class PromptTemplates:
|
||||
"""
|
||||
Manages Jinja2 templates for AI prompt generation.
|
||||
|
||||
Provides caching, helper functions, and consistent template rendering
|
||||
for all AI prompt types.
|
||||
"""
|
||||
|
||||
# Template directory relative to this module
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
def __init__(self, template_dir: Path | str | None = None):
|
||||
"""
|
||||
Initialize the prompt template system.
|
||||
|
||||
Args:
|
||||
template_dir: Optional custom template directory path.
|
||||
"""
|
||||
self.template_dir = Path(template_dir) if template_dir else self.TEMPLATE_DIR
|
||||
|
||||
# Ensure template directory exists
|
||||
if not self.template_dir.exists():
|
||||
self.template_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.warning(
|
||||
"Template directory created",
|
||||
path=str(self.template_dir)
|
||||
)
|
||||
|
||||
# Set up Jinja2 environment with caching
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(str(self.template_dir)),
|
||||
autoescape=select_autoescape(['html', 'xml']),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
# Register custom filters
|
||||
self._register_filters()
|
||||
|
||||
# Register custom globals
|
||||
self._register_globals()
|
||||
|
||||
logger.info(
|
||||
"PromptTemplates initialized",
|
||||
template_dir=str(self.template_dir)
|
||||
)
|
||||
|
||||
def _register_filters(self):
|
||||
"""Register custom Jinja2 filters."""
|
||||
self.env.filters['format_inventory'] = self._format_inventory
|
||||
self.env.filters['format_stats'] = self._format_stats
|
||||
self.env.filters['format_skills'] = self._format_skills
|
||||
self.env.filters['format_effects'] = self._format_effects
|
||||
self.env.filters['truncate_text'] = self._truncate_text
|
||||
self.env.filters['format_gold'] = self._format_gold
|
||||
|
||||
def _register_globals(self):
|
||||
"""Register global functions available in templates."""
|
||||
self.env.globals['len'] = len
|
||||
self.env.globals['min'] = min
|
||||
self.env.globals['max'] = max
|
||||
self.env.globals['enumerate'] = enumerate
|
||||
|
||||
# Custom filters
|
||||
@staticmethod
|
||||
def _format_inventory(items: list[dict], max_items: int = 10) -> str:
|
||||
"""
|
||||
Format inventory items for prompt context.
|
||||
|
||||
Args:
|
||||
items: List of item dictionaries with 'name' and 'quantity'.
|
||||
max_items: Maximum number of items to display.
|
||||
|
||||
Returns:
|
||||
Formatted inventory string.
|
||||
"""
|
||||
if not items:
|
||||
return "Empty inventory"
|
||||
|
||||
formatted = []
|
||||
for item in items[:max_items]:
|
||||
name = item.get('name', 'Unknown')
|
||||
qty = item.get('quantity', 1)
|
||||
if qty > 1:
|
||||
formatted.append(f"{name} (x{qty})")
|
||||
else:
|
||||
formatted.append(name)
|
||||
|
||||
result = ", ".join(formatted)
|
||||
if len(items) > max_items:
|
||||
result += f", and {len(items) - max_items} more items"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_stats(stats: dict) -> str:
|
||||
"""
|
||||
Format character stats for prompt context.
|
||||
|
||||
Args:
|
||||
stats: Dictionary of stat names to values.
|
||||
|
||||
Returns:
|
||||
Formatted stats string.
|
||||
"""
|
||||
if not stats:
|
||||
return "No stats available"
|
||||
|
||||
formatted = []
|
||||
for stat, value in stats.items():
|
||||
# Convert snake_case to Title Case
|
||||
display_name = stat.replace('_', ' ').title()
|
||||
formatted.append(f"{display_name}: {value}")
|
||||
|
||||
return ", ".join(formatted)
|
||||
|
||||
@staticmethod
|
||||
def _format_skills(skills: list[dict], max_skills: int = 5) -> str:
|
||||
"""
|
||||
Format character skills for prompt context.
|
||||
|
||||
Args:
|
||||
skills: List of skill dictionaries with 'name' and 'level'.
|
||||
max_skills: Maximum number of skills to display.
|
||||
|
||||
Returns:
|
||||
Formatted skills string.
|
||||
"""
|
||||
if not skills:
|
||||
return "No skills"
|
||||
|
||||
formatted = []
|
||||
for skill in skills[:max_skills]:
|
||||
name = skill.get('name', 'Unknown')
|
||||
level = skill.get('level', 1)
|
||||
formatted.append(f"{name} (Lv.{level})")
|
||||
|
||||
result = ", ".join(formatted)
|
||||
if len(skills) > max_skills:
|
||||
result += f", and {len(skills) - max_skills} more skills"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_effects(effects: list[dict]) -> str:
|
||||
"""
|
||||
Format active effects/buffs/debuffs for prompt context.
|
||||
|
||||
Args:
|
||||
effects: List of effect dictionaries.
|
||||
|
||||
Returns:
|
||||
Formatted effects string.
|
||||
"""
|
||||
if not effects:
|
||||
return "No active effects"
|
||||
|
||||
formatted = []
|
||||
for effect in effects:
|
||||
name = effect.get('name', 'Unknown')
|
||||
duration = effect.get('remaining_turns')
|
||||
if duration:
|
||||
formatted.append(f"{name} ({duration} turns)")
|
||||
else:
|
||||
formatted.append(name)
|
||||
|
||||
return ", ".join(formatted)
|
||||
|
||||
@staticmethod
|
||||
def _truncate_text(text: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Truncate text to maximum length with ellipsis.
|
||||
|
||||
Args:
|
||||
text: Text to truncate.
|
||||
max_length: Maximum character length.
|
||||
|
||||
Returns:
|
||||
Truncated text with ellipsis if needed.
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length - 3] + "..."
|
||||
|
||||
@staticmethod
|
||||
def _format_gold(amount: int) -> str:
|
||||
"""
|
||||
Format gold amount with commas.
|
||||
|
||||
Args:
|
||||
amount: Gold amount.
|
||||
|
||||
Returns:
|
||||
Formatted gold string.
|
||||
"""
|
||||
return f"{amount:,} gold"
|
||||
|
||||
def render(self, template_name: str, **context: Any) -> str:
|
||||
"""
|
||||
Render a template with the given context.
|
||||
|
||||
Args:
|
||||
template_name: Name of the template file (e.g., 'story_action.j2').
|
||||
**context: Variables to pass to the template.
|
||||
|
||||
Returns:
|
||||
Rendered template string.
|
||||
|
||||
Raises:
|
||||
PromptTemplateError: If template not found or rendering fails.
|
||||
"""
|
||||
try:
|
||||
template = self.env.get_template(template_name)
|
||||
rendered = template.render(**context)
|
||||
|
||||
logger.debug(
|
||||
"Template rendered",
|
||||
template=template_name,
|
||||
context_keys=list(context.keys()),
|
||||
output_length=len(rendered)
|
||||
)
|
||||
|
||||
return rendered.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Template rendering failed",
|
||||
template=template_name,
|
||||
error=str(e)
|
||||
)
|
||||
raise PromptTemplateError(f"Failed to render {template_name}: {e}")
|
||||
|
||||
def render_string(self, template_string: str, **context: Any) -> str:
|
||||
"""
|
||||
Render a template string directly.
|
||||
|
||||
Args:
|
||||
template_string: Jinja2 template string.
|
||||
**context: Variables to pass to the template.
|
||||
|
||||
Returns:
|
||||
Rendered string.
|
||||
|
||||
Raises:
|
||||
PromptTemplateError: If rendering fails.
|
||||
"""
|
||||
try:
|
||||
template = self.env.from_string(template_string)
|
||||
rendered = template.render(**context)
|
||||
return rendered.strip()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"String template rendering failed",
|
||||
error=str(e)
|
||||
)
|
||||
raise PromptTemplateError(f"Failed to render template string: {e}")
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""
|
||||
Get list of available template names.
|
||||
|
||||
Returns:
|
||||
List of template file names.
|
||||
"""
|
||||
return self.env.list_templates(extensions=['j2'])
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_templates: PromptTemplates | None = None
|
||||
|
||||
|
||||
def get_prompt_templates() -> PromptTemplates:
|
||||
"""
|
||||
Get the global PromptTemplates instance.
|
||||
|
||||
Returns:
|
||||
Singleton PromptTemplates instance.
|
||||
"""
|
||||
global _templates
|
||||
if _templates is None:
|
||||
_templates = PromptTemplates()
|
||||
return _templates
|
||||
|
||||
|
||||
def render_prompt(template_name: str, **context: Any) -> str:
|
||||
"""
|
||||
Convenience function to render a prompt template.
|
||||
|
||||
Args:
|
||||
template_name: Name of the template file.
|
||||
**context: Variables to pass to the template.
|
||||
|
||||
Returns:
|
||||
Rendered template string.
|
||||
"""
|
||||
return get_prompt_templates().render(template_name, **context)
|
||||
450
api/app/ai/replicate_client.py
Normal file
450
api/app/ai/replicate_client.py
Normal file
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Replicate API client for AI model integration.
|
||||
|
||||
This module provides a client for interacting with the Replicate API
|
||||
to generate text using various models including Llama-3 and Claude models.
|
||||
All AI generation goes through Replicate for unified billing and management.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import replicate
|
||||
import structlog
|
||||
|
||||
from app.config import get_config
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ModelType(str, Enum):
|
||||
"""Supported model types on Replicate."""
|
||||
# Free tier - Llama models
|
||||
LLAMA_3_8B = "meta/meta-llama-3-8b-instruct"
|
||||
|
||||
# Paid tiers - Claude models via Replicate
|
||||
CLAUDE_HAIKU = "anthropic/claude-3.5-haiku"
|
||||
CLAUDE_SONNET = "anthropic/claude-3.5-sonnet"
|
||||
CLAUDE_SONNET_4 = "anthropic/claude-4.5-sonnet"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicateResponse:
|
||||
"""Response from Replicate API generation."""
|
||||
text: str
|
||||
tokens_used: int # Deprecated: use tokens_output instead
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
model: str
|
||||
generation_time: float
|
||||
|
||||
|
||||
class ReplicateClientError(Exception):
|
||||
"""Base exception for Replicate client errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateAPIError(ReplicateClientError):
|
||||
"""Error from Replicate API."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateRateLimitError(ReplicateClientError):
|
||||
"""Rate limit exceeded on Replicate API."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateTimeoutError(ReplicateClientError):
|
||||
"""Timeout waiting for Replicate response."""
|
||||
pass
|
||||
|
||||
|
||||
class ReplicateClient:
|
||||
"""
|
||||
Client for interacting with Replicate API.
|
||||
|
||||
Supports multiple models including Llama-3 and Claude models.
|
||||
Implements retry logic with exponential backoff for rate limits.
|
||||
"""
|
||||
|
||||
# Default model for free tier
|
||||
DEFAULT_MODEL = ModelType.LLAMA_3_8B
|
||||
|
||||
# Retry configuration
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_RETRY_DELAY = 1.0 # seconds
|
||||
|
||||
# Default generation parameters
|
||||
DEFAULT_MAX_TOKENS = 256
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_TOP_P = 0.9
|
||||
DEFAULT_TIMEOUT = 30 # seconds
|
||||
|
||||
# Model-specific defaults
|
||||
MODEL_DEFAULTS = {
|
||||
ModelType.LLAMA_3_8B: {
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
ModelType.CLAUDE_HAIKU: {
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.8,
|
||||
},
|
||||
ModelType.CLAUDE_SONNET: {
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
ModelType.CLAUDE_SONNET_4: {
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, api_token: str | None = None, model: str | ModelType | None = None):
|
||||
"""
|
||||
Initialize the Replicate client.
|
||||
|
||||
Args:
|
||||
api_token: Replicate API token. If not provided, reads from config.
|
||||
model: Model identifier or ModelType enum. Defaults to Llama-3 8B Instruct.
|
||||
|
||||
Raises:
|
||||
ReplicateClientError: If API token is not configured.
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
# Get API token from parameter or config
|
||||
self.api_token = api_token or getattr(config, 'replicate_api_token', None)
|
||||
if not self.api_token:
|
||||
raise ReplicateClientError(
|
||||
"Replicate API token not configured. "
|
||||
"Set REPLICATE_API_TOKEN in environment or config."
|
||||
)
|
||||
|
||||
# Get model from parameter, config, or default
|
||||
if model is None:
|
||||
model = getattr(config, 'REPLICATE_MODEL', None) or self.DEFAULT_MODEL
|
||||
|
||||
# Convert string to ModelType if needed, or keep as string for custom models
|
||||
if isinstance(model, ModelType):
|
||||
self.model = model.value
|
||||
self.model_type = model
|
||||
elif isinstance(model, str):
|
||||
# Try to match to ModelType
|
||||
self.model = model
|
||||
self.model_type = self._get_model_type(model)
|
||||
else:
|
||||
self.model = self.DEFAULT_MODEL.value
|
||||
self.model_type = self.DEFAULT_MODEL
|
||||
|
||||
# Set the API token for the replicate library
|
||||
import os
|
||||
os.environ['REPLICATE_API_TOKEN'] = self.api_token
|
||||
|
||||
logger.info(
|
||||
"Replicate client initialized",
|
||||
model=self.model,
|
||||
model_type=self.model_type.name if self.model_type else "custom"
|
||||
)
|
||||
|
||||
def _get_model_type(self, model_string: str) -> ModelType | None:
|
||||
"""Get ModelType enum from model string."""
|
||||
for model_type in ModelType:
|
||||
if model_type.value == model_string:
|
||||
return model_type
|
||||
return None
|
||||
|
||||
def _is_claude_model(self) -> bool:
|
||||
"""Check if current model is a Claude model."""
|
||||
return self.model_type in [
|
||||
ModelType.CLAUDE_HAIKU,
|
||||
ModelType.CLAUDE_SONNET,
|
||||
ModelType.CLAUDE_SONNET_4
|
||||
]
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
timeout: int | None = None,
|
||||
model: str | ModelType | None = None
|
||||
) -> ReplicateResponse:
|
||||
"""
|
||||
Generate text using the configured model.
|
||||
|
||||
Args:
|
||||
prompt: The user prompt to send to the model.
|
||||
system_prompt: Optional system prompt for context setting.
|
||||
max_tokens: Maximum tokens to generate. Uses model defaults if not specified.
|
||||
temperature: Sampling temperature (0.0-1.0). Uses model defaults if not specified.
|
||||
top_p: Top-p sampling parameter. Defaults to 0.9.
|
||||
timeout: Timeout in seconds. Defaults to 30.
|
||||
model: Override the default model for this request.
|
||||
|
||||
Returns:
|
||||
ReplicateResponse with generated text and metadata.
|
||||
|
||||
Raises:
|
||||
ReplicateAPIError: For API errors.
|
||||
ReplicateRateLimitError: When rate limited.
|
||||
ReplicateTimeoutError: When request times out.
|
||||
"""
|
||||
# Handle model override
|
||||
if model:
|
||||
if isinstance(model, ModelType):
|
||||
current_model = model.value
|
||||
current_model_type = model
|
||||
else:
|
||||
current_model = model
|
||||
current_model_type = self._get_model_type(model)
|
||||
else:
|
||||
current_model = self.model
|
||||
current_model_type = self.model_type
|
||||
|
||||
# Get model-specific defaults
|
||||
model_defaults = self.MODEL_DEFAULTS.get(current_model_type, {})
|
||||
|
||||
# Apply defaults (parameter > model default > class default)
|
||||
max_tokens = max_tokens or model_defaults.get("max_tokens", self.DEFAULT_MAX_TOKENS)
|
||||
temperature = temperature or model_defaults.get("temperature", self.DEFAULT_TEMPERATURE)
|
||||
top_p = top_p or self.DEFAULT_TOP_P
|
||||
timeout = timeout or self.DEFAULT_TIMEOUT
|
||||
|
||||
# Format prompt based on model type
|
||||
is_claude = current_model_type in [
|
||||
ModelType.CLAUDE_HAIKU,
|
||||
ModelType.CLAUDE_SONNET,
|
||||
ModelType.CLAUDE_SONNET_4
|
||||
]
|
||||
|
||||
if is_claude:
|
||||
input_params = self._build_claude_params(
|
||||
prompt, system_prompt, max_tokens, temperature, top_p
|
||||
)
|
||||
else:
|
||||
# Llama-style formatting
|
||||
formatted_prompt = self._format_llama_prompt(prompt, system_prompt)
|
||||
input_params = {
|
||||
"prompt": formatted_prompt,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Generating text with Replicate",
|
||||
model=current_model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
is_claude=is_claude
|
||||
)
|
||||
|
||||
# Execute with retry logic
|
||||
start_time = time.time()
|
||||
output = self._execute_with_retry(current_model, input_params, timeout)
|
||||
generation_time = time.time() - start_time
|
||||
|
||||
# Parse response
|
||||
text = self._parse_response(output)
|
||||
|
||||
# Estimate tokens (rough approximation: ~4 chars per token)
|
||||
# Calculate input tokens from the actual prompt sent
|
||||
prompt_text = input_params.get("prompt", "")
|
||||
system_text = input_params.get("system_prompt", "")
|
||||
total_input_text = prompt_text + system_text
|
||||
tokens_input = len(total_input_text) // 4
|
||||
|
||||
# Calculate output tokens from response
|
||||
tokens_output = len(text) // 4
|
||||
|
||||
# Total for backwards compatibility
|
||||
tokens_used = tokens_input + tokens_output
|
||||
|
||||
logger.info(
|
||||
"Replicate generation complete",
|
||||
model=current_model,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
tokens_used=tokens_used,
|
||||
generation_time=f"{generation_time:.2f}s",
|
||||
response_length=len(text)
|
||||
)
|
||||
|
||||
return ReplicateResponse(
|
||||
text=text.strip(),
|
||||
tokens_used=tokens_used,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
model=current_model,
|
||||
generation_time=generation_time
|
||||
)
|
||||
|
||||
def _build_claude_params(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str | None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
top_p: float
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build input parameters for Claude models on Replicate.
|
||||
|
||||
Args:
|
||||
prompt: User prompt.
|
||||
system_prompt: Optional system prompt.
|
||||
max_tokens: Maximum tokens to generate.
|
||||
temperature: Sampling temperature.
|
||||
top_p: Top-p sampling parameter.
|
||||
|
||||
Returns:
|
||||
Dictionary of input parameters for Replicate API.
|
||||
"""
|
||||
params = {
|
||||
"prompt": prompt,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
params["system_prompt"] = system_prompt
|
||||
|
||||
return params
|
||||
|
||||
def _format_llama_prompt(self, prompt: str, system_prompt: str | None = None) -> str:
|
||||
"""
|
||||
Format prompt for Llama-3 Instruct model.
|
||||
|
||||
Llama-3 Instruct uses a specific format with special tokens.
|
||||
|
||||
Args:
|
||||
prompt: User prompt.
|
||||
system_prompt: Optional system prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if system_prompt:
|
||||
parts.append(f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n{system_prompt}<|eot_id|>")
|
||||
else:
|
||||
parts.append("<|begin_of_text|>")
|
||||
|
||||
parts.append(f"<|start_header_id|>user<|end_header_id|>\n\n{prompt}<|eot_id|>")
|
||||
parts.append("<|start_header_id|>assistant<|end_header_id|>\n\n")
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def _parse_response(self, output: Any) -> str:
|
||||
"""
|
||||
Parse response from Replicate API.
|
||||
|
||||
Handles both streaming iterators and direct string responses.
|
||||
|
||||
Args:
|
||||
output: Raw output from Replicate API.
|
||||
|
||||
Returns:
|
||||
Parsed text string.
|
||||
"""
|
||||
if hasattr(output, '__iter__') and not isinstance(output, str):
|
||||
return "".join(output)
|
||||
return str(output)
|
||||
|
||||
def _execute_with_retry(
|
||||
self,
|
||||
model: str,
|
||||
input_params: dict[str, Any],
|
||||
timeout: int
|
||||
) -> Any:
|
||||
"""
|
||||
Execute Replicate API call with retry logic.
|
||||
|
||||
Implements exponential backoff for rate limit errors.
|
||||
|
||||
Args:
|
||||
model: Model identifier to run.
|
||||
input_params: Input parameters for the model.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
API response output.
|
||||
|
||||
Raises:
|
||||
ReplicateAPIError: For API errors after retries.
|
||||
ReplicateRateLimitError: When rate limit persists after retries.
|
||||
ReplicateTimeoutError: When request times out.
|
||||
"""
|
||||
last_error = None
|
||||
retry_delay = self.INITIAL_RETRY_DELAY
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
output = replicate.run(
|
||||
model,
|
||||
input=input_params
|
||||
)
|
||||
return output
|
||||
|
||||
except replicate.exceptions.ReplicateError as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "rate limit" in error_message or "429" in error_message:
|
||||
last_error = ReplicateRateLimitError(f"Rate limited: {e}")
|
||||
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
"Rate limited, retrying",
|
||||
attempt=attempt + 1,
|
||||
retry_delay=retry_delay
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
continue
|
||||
else:
|
||||
raise last_error
|
||||
|
||||
elif "timeout" in error_message:
|
||||
raise ReplicateTimeoutError(f"Request timed out: {e}")
|
||||
|
||||
else:
|
||||
raise ReplicateAPIError(f"API error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "timeout" in error_message:
|
||||
raise ReplicateTimeoutError(f"Request timed out: {e}")
|
||||
|
||||
raise ReplicateAPIError(f"Unexpected error: {e}")
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise ReplicateAPIError("Max retries exceeded")
|
||||
|
||||
def validate_api_key(self) -> bool:
|
||||
"""
|
||||
Validate that the API key is valid.
|
||||
|
||||
Makes a minimal API call to check credentials.
|
||||
|
||||
Returns:
|
||||
True if API key is valid, False otherwise.
|
||||
"""
|
||||
try:
|
||||
model_name = self.model.split(':')[0]
|
||||
model = replicate.models.get(model_name)
|
||||
return model is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"API key validation failed",
|
||||
error=str(e)
|
||||
)
|
||||
return False
|
||||
160
api/app/ai/response_parser.py
Normal file
160
api/app/ai/response_parser.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Response parser for AI narrative responses.
|
||||
|
||||
This module handles AI response parsing. Game state changes (items, gold, XP)
|
||||
are now handled exclusively through predetermined dice check outcomes in
|
||||
action templates, not through AI-generated JSON.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemGrant:
|
||||
"""
|
||||
Represents an item granted by the AI during gameplay.
|
||||
|
||||
The AI can grant items in two ways:
|
||||
1. By item_id - References an existing item from game data
|
||||
2. By name/type/description - Creates a generic item
|
||||
"""
|
||||
item_id: Optional[str] = None # For existing items
|
||||
name: Optional[str] = None # For generic items
|
||||
item_type: Optional[str] = None # consumable, weapon, armor, quest_item
|
||||
description: Optional[str] = None
|
||||
value: int = 0
|
||||
quantity: int = 1
|
||||
|
||||
def is_existing_item(self) -> bool:
|
||||
"""Check if this references an existing item by ID."""
|
||||
return self.item_id is not None
|
||||
|
||||
def is_generic_item(self) -> bool:
|
||||
"""Check if this is a generic item created by the AI."""
|
||||
return self.item_id is None and self.name is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameStateChanges:
|
||||
"""
|
||||
Structured game state changes extracted from AI response.
|
||||
|
||||
These changes are validated and applied to the character after
|
||||
the AI generates its narrative response.
|
||||
"""
|
||||
items_given: list[ItemGrant] = field(default_factory=list)
|
||||
items_taken: list[str] = field(default_factory=list) # item_ids to remove
|
||||
gold_given: int = 0
|
||||
gold_taken: int = 0
|
||||
experience_given: int = 0
|
||||
quest_offered: Optional[str] = None # quest_id
|
||||
quest_completed: Optional[str] = None # quest_id
|
||||
location_change: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedAIResponse:
|
||||
"""
|
||||
Complete parsed AI response with narrative and game state changes.
|
||||
|
||||
Attributes:
|
||||
narrative: The narrative text to display to the player
|
||||
game_changes: Structured game state changes to apply
|
||||
raw_response: The original unparsed response from AI
|
||||
parse_success: Whether parsing succeeded
|
||||
parse_errors: Any errors encountered during parsing
|
||||
"""
|
||||
narrative: str
|
||||
game_changes: GameStateChanges
|
||||
raw_response: str
|
||||
parse_success: bool = True
|
||||
parse_errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ResponseParserError(Exception):
|
||||
"""Exception raised when response parsing fails critically."""
|
||||
pass
|
||||
|
||||
|
||||
def parse_ai_response(response_text: str) -> ParsedAIResponse:
|
||||
"""
|
||||
Parse an AI response to extract the narrative text.
|
||||
|
||||
Game state changes (items, gold, XP) are now handled exclusively through
|
||||
predetermined dice check outcomes, not through AI-generated structured data.
|
||||
|
||||
Args:
|
||||
response_text: The raw AI response text
|
||||
|
||||
Returns:
|
||||
ParsedAIResponse with narrative (game_changes will be empty)
|
||||
"""
|
||||
logger.debug("Parsing AI response", response_length=len(response_text))
|
||||
|
||||
# Return the full response as narrative
|
||||
# Game state changes come from predetermined check_outcomes, not AI
|
||||
return ParsedAIResponse(
|
||||
narrative=response_text.strip(),
|
||||
game_changes=GameStateChanges(),
|
||||
raw_response=response_text,
|
||||
parse_success=True,
|
||||
parse_errors=[]
|
||||
)
|
||||
|
||||
|
||||
def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges:
|
||||
"""
|
||||
Parse the game actions dictionary into a GameStateChanges object.
|
||||
|
||||
Args:
|
||||
data: Dictionary from parsed JSON
|
||||
|
||||
Returns:
|
||||
GameStateChanges object with parsed data
|
||||
"""
|
||||
changes = GameStateChanges()
|
||||
|
||||
# Parse items_given
|
||||
if "items_given" in data and data["items_given"]:
|
||||
for item_data in data["items_given"]:
|
||||
if isinstance(item_data, dict):
|
||||
item_grant = ItemGrant(
|
||||
item_id=item_data.get("item_id"),
|
||||
name=item_data.get("name"),
|
||||
item_type=item_data.get("type"),
|
||||
description=item_data.get("description"),
|
||||
value=item_data.get("value", 0),
|
||||
quantity=item_data.get("quantity", 1)
|
||||
)
|
||||
changes.items_given.append(item_grant)
|
||||
elif isinstance(item_data, str):
|
||||
# Simple string format - treat as item_id
|
||||
changes.items_given.append(ItemGrant(item_id=item_data))
|
||||
|
||||
# Parse items_taken
|
||||
if "items_taken" in data and data["items_taken"]:
|
||||
changes.items_taken = [
|
||||
item_id for item_id in data["items_taken"]
|
||||
if isinstance(item_id, str)
|
||||
]
|
||||
|
||||
# Parse gold changes
|
||||
changes.gold_given = int(data.get("gold_given", 0))
|
||||
changes.gold_taken = int(data.get("gold_taken", 0))
|
||||
|
||||
# Parse experience
|
||||
changes.experience_given = int(data.get("experience_given", 0))
|
||||
|
||||
# Parse quest changes
|
||||
changes.quest_offered = data.get("quest_offered")
|
||||
changes.quest_completed = data.get("quest_completed")
|
||||
|
||||
# Parse location change
|
||||
changes.location_change = data.get("location_change")
|
||||
|
||||
return changes
|
||||
81
api/app/ai/templates/combat_action.j2
Normal file
81
api/app/ai/templates/combat_action.j2
Normal file
@@ -0,0 +1,81 @@
|
||||
{#
|
||||
Combat Action Prompt Template
|
||||
Used for narrating combat actions and outcomes.
|
||||
|
||||
Required context:
|
||||
- character: Character object
|
||||
- combat_state: Current combat information
|
||||
- action: The combat action being taken
|
||||
- action_result: Outcome of the action (damage, effects, etc.)
|
||||
|
||||
Optional context:
|
||||
- is_critical: Whether this was a critical hit/miss
|
||||
- is_finishing_blow: Whether this defeats the enemy
|
||||
#}
|
||||
You are the Dungeon Master narrating an exciting combat encounter.
|
||||
|
||||
## Combatants
|
||||
**{{ character.name }}** (Level {{ character.level }} {{ character.player_class }})
|
||||
- Health: {{ character.current_hp }}/{{ character.max_hp }} HP
|
||||
{% if character.effects %}
|
||||
- Active Effects: {{ character.effects | format_effects }}
|
||||
{% endif %}
|
||||
|
||||
**vs**
|
||||
|
||||
{% for enemy in combat_state.enemies %}
|
||||
**{{ enemy.name }}**
|
||||
- Health: {{ enemy.current_hp }}/{{ enemy.max_hp }} HP
|
||||
{% if enemy.effects %}
|
||||
- Status: {{ enemy.effects | format_effects }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
## Combat Round {{ combat_state.round_number }}
|
||||
Turn: {{ combat_state.current_turn }}
|
||||
|
||||
## Action Taken
|
||||
{{ character.name }} {{ action }}
|
||||
|
||||
## Action Result
|
||||
{% if action_result.hit %}
|
||||
- **Hit!** {{ action_result.damage }} damage dealt
|
||||
{% if is_critical %}
|
||||
- **CRITICAL HIT!**
|
||||
{% endif %}
|
||||
{% if action_result.effects_applied %}
|
||||
- Applied: {{ action_result.effects_applied | join(', ') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- **Miss!** The attack fails to connect
|
||||
{% endif %}
|
||||
|
||||
{% if is_finishing_blow %}
|
||||
**{{ action_result.target }} has been defeated!**
|
||||
{% endif %}
|
||||
|
||||
## Your Task
|
||||
Narrate this combat action:
|
||||
1. Describe the action with visceral, cinematic detail
|
||||
2. Show the result - the impact, the enemy's reaction
|
||||
{% if is_finishing_blow %}
|
||||
3. Describe the enemy's defeat dramatically
|
||||
{% else %}
|
||||
3. Hint at the enemy's remaining threat or weakness
|
||||
{% endif %}
|
||||
|
||||
{% if max_tokens %}
|
||||
**IMPORTANT: Your response must be under {{ (max_tokens * 0.6) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.**
|
||||
{% if max_tokens <= 150 %}
|
||||
Keep it to 2-3 punchy sentences.
|
||||
{% elif max_tokens <= 300 %}
|
||||
Keep it to 1 short paragraph.
|
||||
{% else %}
|
||||
Keep it to 1-2 exciting paragraphs.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
Keep it punchy and action-packed. Use active voice and dynamic verbs.
|
||||
Don't include game mechanics in the narrative - just the story.
|
||||
|
||||
Respond only with the narrative - no dice rolls or damage numbers.
|
||||
138
api/app/ai/templates/npc_dialogue.j2
Normal file
138
api/app/ai/templates/npc_dialogue.j2
Normal file
@@ -0,0 +1,138 @@
|
||||
{#
|
||||
NPC Dialogue Prompt Template - Enhanced with persistent NPC data.
|
||||
Used for generating contextual NPC conversations with rich personality.
|
||||
|
||||
Required context:
|
||||
- character: Player character information (name, level, player_class)
|
||||
- npc: NPC information with personality, appearance, dialogue_hooks
|
||||
- conversation_topic: What the player wants to discuss
|
||||
- game_state: Current game state
|
||||
|
||||
Optional context:
|
||||
- npc_knowledge: List of information the NPC can share
|
||||
- revealed_secrets: Secrets being revealed this conversation
|
||||
- interaction_count: Number of times player has talked to this NPC
|
||||
- relationship_level: 0-100 relationship score (50 is neutral)
|
||||
- previous_dialogue: Previous exchanges with this NPC
|
||||
#}
|
||||
You are roleplaying as an NPC in a fantasy world, having a conversation with a player character.
|
||||
|
||||
## The NPC
|
||||
**{{ npc.name }}** - {{ npc.role }}
|
||||
|
||||
{% if npc.appearance %}
|
||||
- **Appearance:** {{ npc.appearance if npc.appearance is string else npc.appearance.brief if npc.appearance.brief else npc.appearance }}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.personality %}
|
||||
{% if npc.personality.traits %}
|
||||
- **Personality Traits:** {{ npc.personality.traits | join(', ') }}
|
||||
{% elif npc.personality is string %}
|
||||
- **Personality:** {{ npc.personality }}
|
||||
{% endif %}
|
||||
{% if npc.personality.speech_style %}
|
||||
- **Speaking Style:** {{ npc.personality.speech_style }}
|
||||
{% endif %}
|
||||
{% if npc.personality.quirks %}
|
||||
- **Quirks:** {{ npc.personality.quirks | join('; ') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.dialogue_hooks and npc.dialogue_hooks.greeting %}
|
||||
- **Typical Greeting:** "{{ npc.dialogue_hooks.greeting }}"
|
||||
{% endif %}
|
||||
|
||||
{% if npc.goals %}
|
||||
- **Current Goals:** {{ npc.goals }}
|
||||
{% endif %}
|
||||
|
||||
## The Player Character
|
||||
**{{ character.name }}** - Level {{ character.level }} {{ character.player_class }}
|
||||
{% if interaction_count and interaction_count > 1 %}
|
||||
- **Familiarity:** This is conversation #{{ interaction_count }} - the NPC recognizes {{ character.name }}
|
||||
{% endif %}
|
||||
{% if relationship_level %}
|
||||
{% if relationship_level >= 80 %}
|
||||
- **Relationship:** Close friend ({{ relationship_level }}/100) - treats player warmly
|
||||
{% elif relationship_level >= 60 %}
|
||||
- **Relationship:** Friendly acquaintance ({{ relationship_level }}/100) - helpful and open
|
||||
{% elif relationship_level >= 40 %}
|
||||
- **Relationship:** Neutral ({{ relationship_level }}/100) - professional but guarded
|
||||
{% elif relationship_level >= 20 %}
|
||||
- **Relationship:** Distrustful ({{ relationship_level }}/100) - wary and curt
|
||||
{% else %}
|
||||
- **Relationship:** Hostile ({{ relationship_level }}/100) - dismissive or antagonistic
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
## Current Setting
|
||||
- **Location:** {{ game_state.current_location }}
|
||||
{% if game_state.time_of_day %}
|
||||
- **Time:** {{ game_state.time_of_day }}
|
||||
{% endif %}
|
||||
{% if game_state.active_quests %}
|
||||
- **Player's Active Quests:** {{ game_state.active_quests | length }}
|
||||
{% endif %}
|
||||
|
||||
{% if npc_knowledge %}
|
||||
## Knowledge the NPC May Share
|
||||
The NPC knows about the following (share naturally as relevant to conversation):
|
||||
{% for info in npc_knowledge %}
|
||||
- {{ info }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if revealed_secrets %}
|
||||
## IMPORTANT: Secrets to Reveal This Conversation
|
||||
Based on the player's relationship with this NPC, naturally reveal the following:
|
||||
{% for secret in revealed_secrets %}
|
||||
- {{ secret }}
|
||||
{% endfor %}
|
||||
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.
|
||||
{% endif %}
|
||||
|
||||
{% if npc.relationships %}
|
||||
## NPC Relationships (for context)
|
||||
{% for rel in npc.relationships %}
|
||||
- Feels {{ rel.attitude }} toward {{ rel.npc_id }}{% if rel.reason %} ({{ rel.reason }}){% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if previous_dialogue %}
|
||||
## Previous Conversation
|
||||
{% for exchange in previous_dialogue[-2:] %}
|
||||
- **{{ character.name }}:** {{ exchange.player_line | truncate_text(100) }}
|
||||
- **{{ npc.name }}:** {{ exchange.npc_response | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
## Player Says
|
||||
"{{ conversation_topic }}"
|
||||
|
||||
## Your Task
|
||||
Respond as {{ npc.name }} in character. Generate dialogue that:
|
||||
1. **Matches the NPC's personality and speech style exactly** - use their quirks, accent, and manner
|
||||
2. **Acknowledges the relationship** - be warmer to friends, cooler to strangers
|
||||
3. **Shares relevant knowledge naturally** - don't info-dump, weave it into conversation
|
||||
4. **Reveals secrets if specified** - make it feel like earned trust, not random exposition
|
||||
5. **Feels alive and memorable** - give this NPC a distinct voice
|
||||
|
||||
{% if max_tokens %}
|
||||
**IMPORTANT: Your response must be under {{ (max_tokens * 0.6) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.**
|
||||
{% if max_tokens <= 150 %}
|
||||
Keep it to 1-2 sentences of dialogue.
|
||||
{% elif max_tokens <= 300 %}
|
||||
Keep it to 2-3 sentences of dialogue.
|
||||
{% else %}
|
||||
Keep it to 2-4 sentences of dialogue.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Keep the response to 2-4 sentences of dialogue.
|
||||
{% endif %}
|
||||
|
||||
You may include brief action/emotion tags in *asterisks* to show gestures and expressions.
|
||||
|
||||
Respond only as the NPC - no narration or out-of-character text.
|
||||
Format: *action/emotion* "Dialogue goes here."
|
||||
61
api/app/ai/templates/quest_offering.j2
Normal file
61
api/app/ai/templates/quest_offering.j2
Normal file
@@ -0,0 +1,61 @@
|
||||
{#
|
||||
Quest Offering Prompt Template
|
||||
Used for AI to select the most contextually appropriate quest.
|
||||
|
||||
Required context:
|
||||
- eligible_quests: List of quest objects that can be offered
|
||||
- game_context: Current game state information
|
||||
- character: Character information
|
||||
|
||||
Optional context:
|
||||
- recent_actions: Recent player actions
|
||||
#}
|
||||
You are selecting the most appropriate quest to offer to a player based on their current context.
|
||||
|
||||
## Player Character
|
||||
**{{ character.name }}** - Level {{ character.level }} {{ character.player_class }}
|
||||
{% if character.completed_quests %}
|
||||
- Completed Quests: {{ character.completed_quests | length }}
|
||||
{% endif %}
|
||||
|
||||
## Current Context
|
||||
- **Location:** {{ game_context.current_location }} ({{ game_context.location_type }})
|
||||
{% if recent_actions %}
|
||||
- **Recent Actions:**
|
||||
{% for action in recent_actions[-3:] %}
|
||||
- {{ action }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if game_context.active_quests %}
|
||||
- **Active Quests:** {{ game_context.active_quests | length }} in progress
|
||||
{% endif %}
|
||||
{% if game_context.world_events %}
|
||||
- **Current Events:** {{ game_context.world_events | join(', ') }}
|
||||
{% endif %}
|
||||
|
||||
## Available Quests
|
||||
{% for quest in eligible_quests %}
|
||||
### {{ loop.index }}. {{ quest.name }}
|
||||
- **Quest ID:** {{ quest.quest_id }}
|
||||
- **Difficulty:** {{ quest.difficulty }}
|
||||
- **Quest Giver:** {{ quest.quest_giver }}
|
||||
- **Description:** {{ quest.description | truncate_text(200) }}
|
||||
- **Narrative Hooks:**
|
||||
{% for hook in quest.narrative_hooks %}
|
||||
- {{ hook }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
## Your Task
|
||||
Select the ONE quest that best fits the current narrative context.
|
||||
|
||||
Consider:
|
||||
1. Which quest's narrative hooks connect best to the player's recent actions?
|
||||
2. Which quest giver makes sense for this location?
|
||||
3. Which difficulty is appropriate for the character's level and situation?
|
||||
4. Which quest would feel most natural to discover right now?
|
||||
|
||||
Respond with ONLY the quest_id of your selection on a single line.
|
||||
Example response: quest_goblin_cave
|
||||
|
||||
Do not include any explanation - just the quest_id.
|
||||
112
api/app/ai/templates/story_action.j2
Normal file
112
api/app/ai/templates/story_action.j2
Normal file
@@ -0,0 +1,112 @@
|
||||
{#
|
||||
Story Action Prompt Template
|
||||
Used for generating DM responses to player story actions.
|
||||
|
||||
Required context:
|
||||
- character: Character object with name, level, player_class, stats
|
||||
- game_state: GameState with current_location, location_type, active_quests
|
||||
- action: String describing the player's action
|
||||
- conversation_history: List of recent conversation entries (optional)
|
||||
|
||||
Optional context:
|
||||
- custom_topic: For specific queries
|
||||
- world_context: Additional world information
|
||||
#}
|
||||
You are the Dungeon Master for {{ character.name }}, a level {{ character.level }} {{ character.player_class }}.
|
||||
|
||||
## Character Status
|
||||
- **Health:** {{ character.current_hp }}/{{ character.max_hp }} HP
|
||||
- **Stats:** {{ character.stats | format_stats }}
|
||||
{% if character.skills %}
|
||||
- **Skills:** {{ character.skills | format_skills }}
|
||||
{% endif %}
|
||||
{% if character.effects %}
|
||||
- **Active Effects:** {{ character.effects | format_effects }}
|
||||
{% endif %}
|
||||
|
||||
## Current Situation
|
||||
- **Location:** {{ game_state.current_location }} ({{ game_state.location_type }})
|
||||
{% if game_state.discovered_locations %}
|
||||
- **Known Locations:** {{ game_state.discovered_locations | join(', ') }}
|
||||
{% endif %}
|
||||
{% if game_state.active_quests %}
|
||||
- **Active Quests:** {{ game_state.active_quests | length }} quest(s) in progress
|
||||
{% endif %}
|
||||
{% if game_state.time_of_day %}
|
||||
- **Time:** {{ game_state.time_of_day }}
|
||||
{% endif %}
|
||||
|
||||
{% if location %}
|
||||
## Location Details
|
||||
- **Place:** {{ location.name }}
|
||||
- **Type:** {{ location.type if location.type else game_state.location_type }}
|
||||
{% if location.description %}
|
||||
- **Description:** {{ location.description | truncate_text(300) }}
|
||||
{% endif %}
|
||||
{% if location.ambient %}
|
||||
- **Atmosphere:** {{ location.ambient | truncate_text(200) }}
|
||||
{% endif %}
|
||||
{% if location.lore %}
|
||||
- **Lore:** {{ location.lore | truncate_text(150) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npcs_present %}
|
||||
## NPCs Present
|
||||
{% for npc in npcs_present %}
|
||||
- **{{ npc.name }}** ({{ npc.role }}): {{ npc.appearance if npc.appearance is string else npc.appearance.brief if npc.appearance else 'No description' }}
|
||||
{% endfor %}
|
||||
These NPCs are available for conversation. Include them naturally in the scene if relevant.
|
||||
{% endif %}
|
||||
|
||||
{% if conversation_history %}
|
||||
## Recent History
|
||||
{% for entry in conversation_history[-3:] %}
|
||||
- **Turn {{ entry.turn }}:** {{ entry.action }}
|
||||
> {{ entry.dm_response }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
## Player Action
|
||||
{{ action }}
|
||||
|
||||
{% if action_instructions %}
|
||||
## Action-Specific Instructions
|
||||
{{ action_instructions }}
|
||||
{% endif %}
|
||||
|
||||
## Your Task
|
||||
Generate a narrative response that:
|
||||
1. Acknowledges the player's action and describes their attempt
|
||||
2. Describes what happens as a result, including any discoveries or consequences
|
||||
3. Sets up the next decision point or opportunity for action
|
||||
|
||||
{% if max_tokens %}
|
||||
**IMPORTANT: Your response must be under {{ (max_tokens * 0.7) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.**
|
||||
{% if max_tokens <= 150 %}
|
||||
Keep it to 1 short paragraph (2-3 sentences).
|
||||
{% elif max_tokens <= 300 %}
|
||||
Keep it to 1 paragraph (4-5 sentences).
|
||||
{% elif max_tokens <= 600 %}
|
||||
Keep it to 1-2 paragraphs.
|
||||
{% else %}
|
||||
Keep it to 2-3 paragraphs.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
Keep the tone immersive and engaging. Use vivid descriptions but stay concise.
|
||||
If the action involves NPCs, give them personality and realistic reactions.
|
||||
If the action could fail or succeed, describe the outcome based on the character's abilities.
|
||||
|
||||
**CRITICAL RULES - Player Agency:**
|
||||
- NEVER make decisions for the player (no auto-purchasing, no automatic commitments)
|
||||
- NEVER complete transactions without explicit player consent
|
||||
- NEVER take items or spend gold without the player choosing to do so
|
||||
- Present options, choices, or discoveries - then let the player decide
|
||||
- End with clear options or a question about what they want to do next
|
||||
- If items/services have costs, always state prices and ask if they want to proceed
|
||||
|
||||
{% if world_context %}
|
||||
## World Context
|
||||
{{ world_context }}
|
||||
{% endif %}
|
||||
0
api/app/api/__init__.py
Normal file
0
api/app/api/__init__.py
Normal file
529
api/app/api/auth.py
Normal file
529
api/app/api/auth.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Authentication API Blueprint
|
||||
|
||||
This module provides API endpoints for user authentication and management:
|
||||
- User registration
|
||||
- User login/logout
|
||||
- Email verification
|
||||
- Password reset
|
||||
|
||||
All endpoints follow the standard API response format defined in app.utils.response.
|
||||
"""
|
||||
|
||||
import re
|
||||
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
error_response,
|
||||
unauthorized_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user, extract_session_token
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_email(email: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate email address format.
|
||||
|
||||
Args:
|
||||
email: Email address to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not email:
|
||||
return False, "Email is required"
|
||||
|
||||
config = get_config()
|
||||
max_length = config.auth.email_max_length
|
||||
|
||||
if len(email) > max_length:
|
||||
return False, f"Email must be no more than {max_length} characters"
|
||||
|
||||
# Email regex pattern
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(pattern, email):
|
||||
return False, "Invalid email format"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_password(password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate password strength.
|
||||
|
||||
Args:
|
||||
password: Password to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not password:
|
||||
return False, "Password is required"
|
||||
|
||||
config = get_config()
|
||||
|
||||
min_length = config.auth.password_min_length
|
||||
if len(password) < min_length:
|
||||
return False, f"Password must be at least {min_length} characters long"
|
||||
|
||||
if len(password) > 128:
|
||||
return False, "Password must be no more than 128 characters"
|
||||
|
||||
errors = []
|
||||
|
||||
if config.auth.password_require_uppercase and not re.search(r'[A-Z]', password):
|
||||
errors.append("at least one uppercase letter")
|
||||
|
||||
if config.auth.password_require_lowercase and not re.search(r'[a-z]', password):
|
||||
errors.append("at least one lowercase letter")
|
||||
|
||||
if config.auth.password_require_number and not re.search(r'[0-9]', password):
|
||||
errors.append("at least one number")
|
||||
|
||||
if config.auth.password_require_special and not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
|
||||
errors.append("at least one special character")
|
||||
|
||||
if errors:
|
||||
return False, f"Password must contain {', '.join(errors)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_name(name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate user name.
|
||||
|
||||
Args:
|
||||
name: Name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not name:
|
||||
return False, "Name is required"
|
||||
|
||||
config = get_config()
|
||||
|
||||
min_length = config.auth.name_min_length
|
||||
max_length = config.auth.name_max_length
|
||||
|
||||
if len(name) < min_length:
|
||||
return False, f"Name must be at least {min_length} characters"
|
||||
|
||||
if len(name) > max_length:
|
||||
return False, f"Name must be no more than {max_length} characters"
|
||||
|
||||
# Allow letters, spaces, hyphens, apostrophes
|
||||
if not re.match(r"^[a-zA-Z\s\-']+$", name):
|
||||
return False, "Name can only contain letters, spaces, hyphens, and apostrophes"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@auth_bp.route('/api/v1/auth/register', methods=['POST'])
|
||||
def api_register():
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "Player Name"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: User created successfully
|
||||
400: Validation error or email already exists
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
email_valid, email_error = validate_email(email)
|
||||
if not email_valid:
|
||||
validation_errors['email'] = email_error
|
||||
|
||||
password_valid, password_error = validate_password(password)
|
||||
if not password_valid:
|
||||
validation_errors['password'] = password_error
|
||||
|
||||
name_valid, name_error = validate_name(name)
|
||||
if not name_valid:
|
||||
validation_errors['name'] = name_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(validation_errors)
|
||||
|
||||
# Register user
|
||||
appwrite = AppwriteService()
|
||||
user_data = appwrite.register_user(email=email, password=password, name=name)
|
||||
|
||||
logger.info("User registered successfully", user_id=user_data.id, email=email)
|
||||
|
||||
return created_response(
|
||||
result={
|
||||
"user": user_data.to_dict(),
|
||||
"message": "Registration successful. Please check your email to verify your account."
|
||||
}
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Registration failed", error=str(e), code=e.code)
|
||||
|
||||
# Check for specific error codes
|
||||
if e.code == 409: # Conflict - user already exists
|
||||
return validation_error_response({"email": "An account with this email already exists"})
|
||||
|
||||
return error_response(message="Registration failed. Please try again.", code="REGISTRATION_ERROR")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during registration", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/login', methods=['POST'])
|
||||
def api_login():
|
||||
"""
|
||||
Authenticate a user and create a session.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"remember_me": false
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Login successful, session cookie set
|
||||
401: Invalid credentials
|
||||
400: Validation error
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
remember_me = data.get('remember_me', False)
|
||||
|
||||
# Validate inputs
|
||||
if not email:
|
||||
return validation_error_response({"email": "Email is required"})
|
||||
|
||||
if not password:
|
||||
return validation_error_response({"password": "Password is required"})
|
||||
|
||||
# Authenticate user
|
||||
appwrite = AppwriteService()
|
||||
session_data, user_data = appwrite.login_user(email=email, password=password)
|
||||
|
||||
logger.info("User logged in successfully", user_id=user_data.id, email=email)
|
||||
|
||||
# Set session cookie
|
||||
config = get_config()
|
||||
duration = config.auth.duration_remember_me if remember_me else config.auth.duration_normal
|
||||
|
||||
response = make_response(success_response(
|
||||
result={
|
||||
"user": user_data.to_dict(),
|
||||
"message": "Login successful"
|
||||
}
|
||||
))
|
||||
|
||||
response.set_cookie(
|
||||
key=config.auth.cookie_name,
|
||||
value=session_data.session_id,
|
||||
max_age=duration,
|
||||
httponly=config.auth.http_only,
|
||||
secure=config.auth.secure,
|
||||
samesite=config.auth.same_site,
|
||||
path=config.auth.path
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.warning("Login failed", email=email if 'email' in locals() else 'unknown', error=str(e), code=e.code)
|
||||
|
||||
# Generic error message for security (don't reveal if email exists)
|
||||
return unauthorized_response(message="Invalid email or password")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during login", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/logout', methods=['POST'])
|
||||
@require_auth
|
||||
def api_logout():
|
||||
"""
|
||||
Log out the current user by deleting their session.
|
||||
|
||||
Returns:
|
||||
200: Logout successful, session cookie cleared
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get session token
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
return unauthorized_response(message="No active session")
|
||||
|
||||
# Logout user
|
||||
appwrite = AppwriteService()
|
||||
appwrite.logout_user(session_id=token)
|
||||
|
||||
user = get_current_user()
|
||||
logger.info("User logged out successfully", user_id=user.id if user else 'unknown')
|
||||
|
||||
# Clear session cookie
|
||||
config = get_config()
|
||||
|
||||
response = make_response(success_response(
|
||||
result={"message": "Logout successful"}
|
||||
))
|
||||
|
||||
response.set_cookie(
|
||||
key=config.auth.cookie_name,
|
||||
value='',
|
||||
max_age=0,
|
||||
httponly=config.auth.http_only,
|
||||
secure=config.auth.secure,
|
||||
samesite=config.auth.same_site,
|
||||
path=config.auth.path
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Logout failed", error=str(e), code=e.code)
|
||||
return error_response(message="Logout failed", code="LOGOUT_ERROR")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during logout", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
|
||||
def api_verify_email():
|
||||
"""
|
||||
Verify a user's email address.
|
||||
|
||||
Query Parameters:
|
||||
userId: User ID from verification link
|
||||
secret: Verification secret from verification link
|
||||
|
||||
Returns:
|
||||
Redirects to login page with success/error message
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('userId')
|
||||
secret = request.args.get('secret')
|
||||
|
||||
if not user_id or not secret:
|
||||
flash("Invalid verification link", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
# Verify email
|
||||
appwrite = AppwriteService()
|
||||
appwrite.verify_email(user_id=user_id, secret=secret)
|
||||
|
||||
logger.info("Email verified successfully", user_id=user_id)
|
||||
|
||||
flash("Email verified successfully! You can now log in.", "success")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Email verification failed", error=str(e), code=e.code)
|
||||
flash("Email verification failed. The link may be invalid or expired.", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during email verification", error=str(e))
|
||||
flash("An unexpected error occurred", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/forgot-password', methods=['POST'])
|
||||
def api_forgot_password():
|
||||
"""
|
||||
Request a password reset email.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Always returns success (for security, don't reveal if email exists)
|
||||
400: Validation error
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
# Validate email
|
||||
email_valid, email_error = validate_email(email)
|
||||
if not email_valid:
|
||||
return validation_error_response({"email": email_error})
|
||||
|
||||
# Request password reset
|
||||
appwrite = AppwriteService()
|
||||
appwrite.request_password_reset(email=email)
|
||||
|
||||
logger.info("Password reset requested", email=email)
|
||||
|
||||
# Always return success for security
|
||||
return success_response(
|
||||
result={
|
||||
"message": "If an account exists with this email, you will receive a password reset link shortly."
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during password reset request", error=str(e))
|
||||
# Still return success for security
|
||||
return success_response(
|
||||
result={
|
||||
"message": "If an account exists with this email, you will receive a password reset link shortly."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/reset-password', methods=['POST'])
|
||||
def api_reset_password():
|
||||
"""
|
||||
Confirm password reset and update password.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"user_id": "user_id_from_link",
|
||||
"secret": "secret_from_link",
|
||||
"password": "NewSecurePass123!"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Password reset successful
|
||||
400: Validation error or invalid/expired link
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
user_id = data.get('user_id', '').strip()
|
||||
secret = data.get('secret', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
if not user_id:
|
||||
validation_errors['user_id'] = "User ID is required"
|
||||
|
||||
if not secret:
|
||||
validation_errors['secret'] = "Reset secret is required"
|
||||
|
||||
password_valid, password_error = validate_password(password)
|
||||
if not password_valid:
|
||||
validation_errors['password'] = password_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(validation_errors)
|
||||
|
||||
# Confirm password reset
|
||||
appwrite = AppwriteService()
|
||||
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
|
||||
|
||||
logger.info("Password reset successfully", user_id=user_id)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Password reset successful. You can now log in with your new password."
|
||||
}
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Password reset failed", error=str(e), code=e.code)
|
||||
return error_response(
|
||||
message="Password reset failed. The link may be invalid or expired.",
|
||||
code="PASSWORD_RESET_ERROR"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during password reset", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
# ===== TEMPLATE ROUTES (for rendering HTML pages) =====
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
"""Render the login page."""
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET'])
|
||||
def register_page():
|
||||
"""Render the registration page."""
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/forgot-password', methods=['GET'])
|
||||
def forgot_password_page():
|
||||
"""Render the forgot password page."""
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
|
||||
@auth_bp.route('/reset-password', methods=['GET'])
|
||||
def reset_password_page():
|
||||
"""Render the reset password page."""
|
||||
user_id = request.args.get('userId', '')
|
||||
secret = request.args.get('secret', '')
|
||||
|
||||
return render_template('auth/reset_password.html', user_id=user_id, secret=secret)
|
||||
898
api/app/api/characters.py
Normal file
898
api/app/api/characters.py
Normal file
@@ -0,0 +1,898 @@
|
||||
"""
|
||||
Character API Blueprint
|
||||
|
||||
This module provides API endpoints for character management:
|
||||
- List user's characters
|
||||
- Get character details
|
||||
- Create new character
|
||||
- Delete character
|
||||
- Unlock skills
|
||||
- Respec skills
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.character_service import (
|
||||
get_character_service,
|
||||
CharacterLimitExceeded,
|
||||
CharacterNotFound,
|
||||
SkillUnlockError,
|
||||
InsufficientGold
|
||||
)
|
||||
from app.services.class_loader import get_class_loader
|
||||
from app.services.origin_service import get_origin_service
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_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
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
characters_bp = Blueprint('characters', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_character_name(name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate character name.
|
||||
|
||||
Args:
|
||||
name: Character name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not name:
|
||||
return False, "Character name is required"
|
||||
|
||||
if len(name) < 2:
|
||||
return False, "Character name must be at least 2 characters"
|
||||
|
||||
if len(name) > 50:
|
||||
return False, "Character name must be no more than 50 characters"
|
||||
|
||||
# Allow letters, spaces, hyphens, apostrophes, and common fantasy characters
|
||||
if not all(c.isalnum() or c in " -'" for c in name):
|
||||
return False, "Character name can only contain letters, numbers, spaces, hyphens, and apostrophes"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_class_id(class_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate class ID.
|
||||
|
||||
Args:
|
||||
class_id: Class ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not class_id:
|
||||
return False, "Class ID is required"
|
||||
|
||||
valid_classes = [
|
||||
'vanguard', 'assassin', 'arcanist', 'luminary',
|
||||
'wildstrider', 'oathkeeper', 'necromancer', 'lorekeeper'
|
||||
]
|
||||
|
||||
if class_id not in valid_classes:
|
||||
return False, f"Invalid class ID. Must be one of: {', '.join(valid_classes)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_origin_id(origin_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate origin ID.
|
||||
|
||||
Args:
|
||||
origin_id: Origin ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not origin_id:
|
||||
return False, "Origin ID is required"
|
||||
|
||||
valid_origins = [
|
||||
'soul_revenant', 'memory_thief', 'shadow_apprentice', 'escaped_captive'
|
||||
]
|
||||
|
||||
if origin_id not in valid_origins:
|
||||
return False, f"Invalid origin ID. Must be one of: {', '.join(valid_origins)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_skill_id(skill_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate skill ID.
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not skill_id:
|
||||
return False, "Skill ID is required"
|
||||
|
||||
if len(skill_id) > 100:
|
||||
return False, "Skill ID is too long"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@characters_bp.route('/api/v1/characters', methods=['GET'])
|
||||
@require_auth
|
||||
def list_characters():
|
||||
"""
|
||||
List all characters owned by the current user.
|
||||
|
||||
Returns:
|
||||
200: List of characters
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"characters": [
|
||||
{
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "warrior",
|
||||
"level": 5,
|
||||
"gold": 1000
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"tier": "free",
|
||||
"limit": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Listing characters", user_id=user.id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get user's characters
|
||||
characters = char_service.get_user_characters(user.id)
|
||||
|
||||
# Get tier information
|
||||
tier = user.tier
|
||||
from app.services.character_service import CHARACTER_LIMITS
|
||||
limit = CHARACTER_LIMITS.get(tier, 1)
|
||||
|
||||
# Convert characters to dict format
|
||||
character_list = [
|
||||
{
|
||||
"character_id": char.character_id,
|
||||
"name": char.name,
|
||||
"class": char.player_class.class_id,
|
||||
"class_name": char.player_class.name,
|
||||
"level": char.level,
|
||||
"experience": char.experience,
|
||||
"gold": char.gold,
|
||||
"current_location": char.current_location,
|
||||
"origin": char.origin.id
|
||||
}
|
||||
for char in characters
|
||||
]
|
||||
|
||||
logger.info("Characters listed successfully",
|
||||
user_id=user.id,
|
||||
count=len(characters))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"characters": character_list,
|
||||
"count": len(characters),
|
||||
"tier": tier,
|
||||
"limit": limit
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list characters",
|
||||
user_id=user.id if user else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_LIST_ERROR",
|
||||
message="Failed to retrieve characters",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_character(character_id: str):
|
||||
"""
|
||||
Get detailed information about a specific character.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Character details
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": {...},
|
||||
"origin": {...},
|
||||
"level": 5,
|
||||
"experience": 250,
|
||||
"base_stats": {...},
|
||||
"unlocked_skills": [...],
|
||||
"inventory": [...],
|
||||
"equipped": {...},
|
||||
"gold": 1000
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Getting character",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get character (ownership validated in service)
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
logger.info("Character retrieved successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
return success_response(result=character.to_dict())
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get character",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_GET_ERROR",
|
||||
message="Failed to retrieve character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters', methods=['POST'])
|
||||
@require_auth
|
||||
def create_character():
|
||||
"""
|
||||
Create a new character for the current user.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"name": "Thorin Ironheart",
|
||||
"class_id": "warrior",
|
||||
"origin_id": "soul_revenant"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: Character created successfully
|
||||
400: Validation error or character limit exceeded
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "warrior",
|
||||
"level": 1,
|
||||
"message": "Character created successfully"
|
||||
}
|
||||
}
|
||||
"""
|
||||
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"}
|
||||
)
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
class_id = data.get('class_id', '').strip().lower()
|
||||
origin_id = data.get('origin_id', '').strip().lower()
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
name_valid, name_error = validate_character_name(name)
|
||||
if not name_valid:
|
||||
validation_errors['name'] = name_error
|
||||
|
||||
class_valid, class_error = validate_class_id(class_id)
|
||||
if not class_valid:
|
||||
validation_errors['class_id'] = class_error
|
||||
|
||||
origin_valid, origin_error = validate_origin_id(origin_id)
|
||||
if not origin_valid:
|
||||
validation_errors['origin_id'] = origin_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details=validation_errors
|
||||
)
|
||||
|
||||
logger.info("Creating character",
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
class_id=class_id,
|
||||
origin_id=origin_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Create character
|
||||
character = char_service.create_character(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
class_id=class_id,
|
||||
origin_id=origin_id
|
||||
)
|
||||
|
||||
logger.info("Character created successfully",
|
||||
user_id=user.id,
|
||||
character_id=character.character_id,
|
||||
name=name)
|
||||
|
||||
return created_response(
|
||||
result={
|
||||
"character_id": character.character_id,
|
||||
"name": character.name,
|
||||
"class": character.player_class.class_id,
|
||||
"class_name": character.player_class.name,
|
||||
"origin": character.origin.id,
|
||||
"origin_name": character.origin.name,
|
||||
"level": character.level,
|
||||
"gold": character.gold,
|
||||
"current_location": character.current_location,
|
||||
"message": "Character created successfully"
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterLimitExceeded as e:
|
||||
logger.warning("Character limit exceeded",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_LIMIT_EXCEEDED",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid class or origin",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return validation_error_response(
|
||||
message=str(e),
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create character",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_CREATE_ERROR",
|
||||
message="Failed to create character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_character(character_id: str):
|
||||
"""
|
||||
Delete a character (soft delete - marks as inactive).
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Character deleted successfully
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Character deleted successfully",
|
||||
"character_id": "char_001"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Deleting character",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Delete character (ownership validated in service)
|
||||
char_service.delete_character(character_id, user.id)
|
||||
|
||||
logger.info("Character deleted successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Character deleted successfully",
|
||||
"character_id": character_id
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for deletion",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete character",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_DELETE_ERROR",
|
||||
message="Failed to delete character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>/skills/unlock', methods=['POST'])
|
||||
@require_auth
|
||||
def unlock_skill(character_id: str):
|
||||
"""
|
||||
Unlock a skill for a character.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"skill_id": "power_strike"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Skill unlocked successfully
|
||||
400: Validation error or unlock requirements not met
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Skill unlocked successfully",
|
||||
"character_id": "char_001",
|
||||
"skill_id": "power_strike",
|
||||
"unlocked_skills": ["power_strike"],
|
||||
"available_points": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
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"}
|
||||
)
|
||||
|
||||
skill_id = data.get('skill_id', '').strip()
|
||||
|
||||
# Validate skill_id
|
||||
skill_valid, skill_error = validate_skill_id(skill_id)
|
||||
if not skill_valid:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details={"skill_id": skill_error}
|
||||
)
|
||||
|
||||
logger.info("Unlocking skill",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Unlock skill (validates ownership, prerequisites, skill points)
|
||||
character = char_service.unlock_skill(character_id, user.id, skill_id)
|
||||
|
||||
# Calculate available skill points
|
||||
available_points = character.level - len(character.unlocked_skills)
|
||||
|
||||
logger.info("Skill unlocked successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id,
|
||||
available_points=available_points)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Skill unlocked successfully",
|
||||
"character_id": character_id,
|
||||
"skill_id": skill_id,
|
||||
"unlocked_skills": character.unlocked_skills,
|
||||
"available_points": available_points
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for skill unlock",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except SkillUnlockError as e:
|
||||
logger.warning("Skill unlock failed",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id if 'skill_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="SKILL_UNLOCK_ERROR",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to unlock skill",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="SKILL_UNLOCK_ERROR",
|
||||
message="Failed to unlock skill",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>/skills/respec', methods=['POST'])
|
||||
@require_auth
|
||||
def respec_skills(character_id: str):
|
||||
"""
|
||||
Reset all unlocked skills for a character.
|
||||
|
||||
Cost: level × 100 gold
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Skills reset successfully
|
||||
400: Insufficient gold
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Skills reset successfully",
|
||||
"character_id": "char_001",
|
||||
"cost": 500,
|
||||
"remaining_gold": 500,
|
||||
"available_points": 5
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Respecing character skills",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get character to calculate cost
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
respec_cost = character.level * 100
|
||||
|
||||
# Respec skills (validates ownership and gold)
|
||||
character = char_service.respec_skills(character_id, user.id)
|
||||
|
||||
# Calculate available skill points
|
||||
available_points = character.level - len(character.unlocked_skills)
|
||||
|
||||
logger.info("Skills respeced successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
cost=respec_cost,
|
||||
remaining_gold=character.gold,
|
||||
available_points=available_points)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Skills reset successfully",
|
||||
"character_id": character_id,
|
||||
"cost": respec_cost,
|
||||
"remaining_gold": character.gold,
|
||||
"available_points": available_points
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for respec",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except InsufficientGold as e:
|
||||
logger.warning("Insufficient gold for respec",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="INSUFFICIENT_GOLD",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to respec skills",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="RESPEC_ERROR",
|
||||
message="Failed to reset skills",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
# ===== CLASSES & ORIGINS ENDPOINTS (Reference Data) =====
|
||||
|
||||
@characters_bp.route('/api/v1/classes', methods=['GET'])
|
||||
def list_classes():
|
||||
"""
|
||||
List all available player classes.
|
||||
|
||||
This endpoint provides reference data for character creation.
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
200: List of all classes with basic info
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"classes": [
|
||||
{
|
||||
"class_id": "vanguard",
|
||||
"name": "Vanguard",
|
||||
"description": "Armored warrior...",
|
||||
"base_stats": {...},
|
||||
"skill_trees": ["Shield Bearer", "Weapon Master"]
|
||||
}
|
||||
],
|
||||
"count": 8
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing all classes")
|
||||
|
||||
# Get class loader
|
||||
class_loader = get_class_loader()
|
||||
|
||||
# Get all class IDs
|
||||
class_ids = class_loader.get_all_class_ids()
|
||||
|
||||
# Load all classes
|
||||
classes = []
|
||||
for class_id in class_ids:
|
||||
player_class = class_loader.load_class(class_id)
|
||||
if player_class:
|
||||
classes.append({
|
||||
"class_id": player_class.class_id,
|
||||
"name": player_class.name,
|
||||
"description": player_class.description,
|
||||
"base_stats": player_class.base_stats.to_dict(),
|
||||
"skill_trees": [tree.name for tree in player_class.skill_trees],
|
||||
"starting_equipment": player_class.starting_equipment,
|
||||
"starting_abilities": player_class.starting_abilities
|
||||
})
|
||||
|
||||
logger.info("Classes listed successfully", count=len(classes))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"classes": classes,
|
||||
"count": len(classes)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list classes", error=str(e))
|
||||
return error_response(
|
||||
code="CLASS_LIST_ERROR",
|
||||
message="Failed to retrieve classes",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/classes/<class_id>', methods=['GET'])
|
||||
def get_class(class_id: str):
|
||||
"""
|
||||
Get detailed information about a specific class.
|
||||
|
||||
This endpoint provides full class data including skill trees.
|
||||
No authentication required.
|
||||
|
||||
Args:
|
||||
class_id: Class ID (e.g., "vanguard", "assassin")
|
||||
|
||||
Returns:
|
||||
200: Full class details with skill trees
|
||||
404: Class not found
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"class_id": "vanguard",
|
||||
"name": "Vanguard",
|
||||
"description": "Armored warrior...",
|
||||
"base_stats": {...},
|
||||
"skill_trees": [
|
||||
{
|
||||
"tree_id": "shield_bearer",
|
||||
"name": "Shield Bearer",
|
||||
"nodes": [...]
|
||||
}
|
||||
],
|
||||
"starting_equipment": [...],
|
||||
"starting_abilities": [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Getting class details", class_id=class_id)
|
||||
|
||||
# Get class loader
|
||||
class_loader = get_class_loader()
|
||||
|
||||
# Load class
|
||||
player_class = class_loader.load_class(class_id)
|
||||
|
||||
if not player_class:
|
||||
logger.warning("Class not found", class_id=class_id)
|
||||
return not_found_response(message=f"Class not found: {class_id}")
|
||||
|
||||
logger.info("Class retrieved successfully", class_id=class_id)
|
||||
|
||||
# Return full class data
|
||||
return success_response(result=player_class.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get class",
|
||||
class_id=class_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CLASS_GET_ERROR",
|
||||
message="Failed to retrieve class",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/origins', methods=['GET'])
|
||||
def list_origins():
|
||||
"""
|
||||
List all available character origins.
|
||||
|
||||
This endpoint provides reference data for character creation.
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
200: List of all origins
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"origins": [
|
||||
{
|
||||
"id": "soul_revenant",
|
||||
"name": "Soul Revenant",
|
||||
"description": "Returned from death...",
|
||||
"starting_location": {...},
|
||||
"narrative_hooks": [...],
|
||||
"starting_bonus": {...}
|
||||
}
|
||||
],
|
||||
"count": 4
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing all origins")
|
||||
|
||||
# Get origin service
|
||||
origin_service = get_origin_service()
|
||||
|
||||
# Get all origin IDs
|
||||
origin_ids = origin_service.get_all_origin_ids()
|
||||
|
||||
# Load all origins
|
||||
origins = []
|
||||
for origin_id in origin_ids:
|
||||
origin = origin_service.load_origin(origin_id)
|
||||
if origin:
|
||||
origins.append(origin.to_dict())
|
||||
|
||||
logger.info("Origins listed successfully", count=len(origins))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"origins": origins,
|
||||
"count": len(origins)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list origins", error=str(e))
|
||||
return error_response(
|
||||
code="ORIGIN_LIST_ERROR",
|
||||
message="Failed to retrieve origins",
|
||||
status=500
|
||||
)
|
||||
302
api/app/api/game_mechanics.py
Normal file
302
api/app/api/game_mechanics.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Game Mechanics API Blueprint
|
||||
|
||||
This module provides API endpoints for game mechanics that determine
|
||||
outcomes before AI narration:
|
||||
- Skill checks (perception, persuasion, stealth, etc.)
|
||||
- Search/loot actions
|
||||
- Dice rolls
|
||||
|
||||
These endpoints return structured results that can be used for UI
|
||||
dice animations and then passed to AI for narrative description.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.game_logic.dice import SkillType, Difficulty
|
||||
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
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
game_mechanics_bp = Blueprint('game_mechanics', __name__, url_prefix='/api/v1/game')
|
||||
|
||||
|
||||
# Valid skill types for API validation
|
||||
VALID_SKILL_TYPES = [skill.name.lower() for skill in SkillType]
|
||||
|
||||
# Valid difficulty names
|
||||
VALID_DIFFICULTIES = ["trivial", "easy", "medium", "hard", "very_hard", "nearly_impossible"]
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/check', methods=['POST'])
|
||||
@require_auth
|
||||
def perform_check():
|
||||
"""
|
||||
Perform a skill check or search action.
|
||||
|
||||
This endpoint determines the outcome of chance-based actions before
|
||||
they are passed to AI for narration. The result includes all dice
|
||||
roll details for UI display.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"character_id": "...",
|
||||
"check_type": "search" | "skill",
|
||||
"skill": "perception", // Required for skill checks
|
||||
"dc": 15, // Optional, can use difficulty instead
|
||||
"difficulty": "medium", // Optional, alternative to dc
|
||||
"location_type": "forest", // For search checks
|
||||
"context": {} // Optional additional context
|
||||
}
|
||||
|
||||
Returns:
|
||||
For search checks:
|
||||
{
|
||||
"check_result": {
|
||||
"roll": 14,
|
||||
"modifier": 3,
|
||||
"total": 17,
|
||||
"dc": 15,
|
||||
"success": true,
|
||||
"margin": 2
|
||||
},
|
||||
"items_found": [...],
|
||||
"gold_found": 5
|
||||
}
|
||||
|
||||
For skill checks:
|
||||
{
|
||||
"check_result": {...},
|
||||
"context": {
|
||||
"skill_used": "persuasion",
|
||||
"stat_used": "charisma",
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"field": "body", "issue": "Missing JSON body"}
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
character_id = data.get("character_id")
|
||||
check_type = data.get("check_type")
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response(
|
||||
message="Character ID is required",
|
||||
details={"field": "character_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if not check_type:
|
||||
return validation_error_response(
|
||||
message="Check type is required",
|
||||
details={"field": "check_type", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if check_type not in ["search", "skill"]:
|
||||
return validation_error_response(
|
||||
message="Invalid check type",
|
||||
details={"field": "check_type", "issue": "Must be 'search' or 'skill'"}
|
||||
)
|
||||
|
||||
# Get character and verify ownership
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if character.user_id != user["user_id"]:
|
||||
return error_response(
|
||||
status_code=403,
|
||||
message="You don't have permission to access this character",
|
||||
error_code="FORBIDDEN"
|
||||
)
|
||||
except CharacterNotFound:
|
||||
return not_found_response(
|
||||
message=f"Character not found: {character_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("character_fetch_error", error=str(e), character_id=character_id)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to fetch character",
|
||||
error_code="CHARACTER_FETCH_ERROR"
|
||||
)
|
||||
|
||||
# Determine DC from difficulty name or direct value
|
||||
dc = data.get("dc")
|
||||
difficulty = data.get("difficulty")
|
||||
|
||||
if dc is None and difficulty:
|
||||
if difficulty.lower() not in VALID_DIFFICULTIES:
|
||||
return validation_error_response(
|
||||
message="Invalid difficulty",
|
||||
details={
|
||||
"field": "difficulty",
|
||||
"issue": f"Must be one of: {', '.join(VALID_DIFFICULTIES)}"
|
||||
}
|
||||
)
|
||||
dc = outcome_service.get_dc_for_difficulty(difficulty)
|
||||
elif dc is None:
|
||||
# Default to medium difficulty
|
||||
dc = Difficulty.MEDIUM.value
|
||||
|
||||
# Validate DC range
|
||||
if not isinstance(dc, int) or dc < 1 or dc > 35:
|
||||
return validation_error_response(
|
||||
message="Invalid DC value",
|
||||
details={"field": "dc", "issue": "DC must be an integer between 1 and 35"}
|
||||
)
|
||||
|
||||
# Get optional bonus
|
||||
bonus = data.get("bonus", 0)
|
||||
if not isinstance(bonus, int):
|
||||
bonus = 0
|
||||
|
||||
# Perform the check based on type
|
||||
try:
|
||||
if check_type == "search":
|
||||
# Search check uses perception
|
||||
location_type = data.get("location_type", "default")
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type,
|
||||
dc=dc,
|
||||
bonus=bonus
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
location_type=location_type,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
else: # skill check
|
||||
skill = data.get("skill")
|
||||
if not skill:
|
||||
return validation_error_response(
|
||||
message="Skill is required for skill checks",
|
||||
details={"field": "skill", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
skill_lower = skill.lower()
|
||||
if skill_lower not in VALID_SKILL_TYPES:
|
||||
return validation_error_response(
|
||||
message="Invalid skill type",
|
||||
details={
|
||||
"field": "skill",
|
||||
"issue": f"Must be one of: {', '.join(VALID_SKILL_TYPES)}"
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to SkillType enum
|
||||
skill_type = SkillType[skill.upper()]
|
||||
|
||||
# Get additional context
|
||||
context = data.get("context", {})
|
||||
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc,
|
||||
bonus=bonus,
|
||||
context=context
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"skill_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
skill=skill_lower,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"check_error",
|
||||
error=str(e),
|
||||
check_type=check_type,
|
||||
character_id=character_id
|
||||
)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to perform check",
|
||||
error_code="CHECK_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/skills', methods=['GET'])
|
||||
def list_skills():
|
||||
"""
|
||||
List all available skill types.
|
||||
|
||||
Returns the skill types available for skill checks,
|
||||
along with their associated base stats.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "perception",
|
||||
"stat": "wisdom",
|
||||
"description": "..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
skills = []
|
||||
for skill in SkillType:
|
||||
skills.append({
|
||||
"name": skill.name.lower(),
|
||||
"stat": skill.value,
|
||||
})
|
||||
|
||||
return success_response(result={"skills": skills})
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/difficulties', methods=['GET'])
|
||||
def list_difficulties():
|
||||
"""
|
||||
List all difficulty levels and their DC values.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"difficulties": [
|
||||
{"name": "trivial", "dc": 5},
|
||||
{"name": "easy", "dc": 10},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
difficulties = []
|
||||
for diff in Difficulty:
|
||||
difficulties.append({
|
||||
"name": diff.name.lower(),
|
||||
"dc": diff.value,
|
||||
})
|
||||
|
||||
return success_response(result={"difficulties": difficulties})
|
||||
60
api/app/api/health.py
Normal file
60
api/app/api/health.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Health Check API Blueprint
|
||||
|
||||
This module provides a simple health check endpoint for monitoring
|
||||
and testing API connectivity.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
from app.utils.response import success_response
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
health_bp = Blueprint('health', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
@health_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns basic service status and version information.
|
||||
Useful for monitoring, load balancers, and testing API connectivity.
|
||||
|
||||
Returns:
|
||||
JSON response with status "ok" and version info
|
||||
|
||||
Example:
|
||||
GET /api/v1/health
|
||||
|
||||
Response:
|
||||
{
|
||||
"app": "Code of Conquest",
|
||||
"version": "0.1.0",
|
||||
"status": 200,
|
||||
"timestamp": "2025-11-16T...",
|
||||
"result": {
|
||||
"status": "ok",
|
||||
"service": "Code of Conquest API",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"error": null,
|
||||
"meta": {}
|
||||
}
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
logger.debug("Health check requested")
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"status": "ok",
|
||||
"service": "Code of Conquest API",
|
||||
"version": config.app.version
|
||||
}
|
||||
)
|
||||
71
api/app/api/jobs.py
Normal file
71
api/app/api/jobs.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Jobs API Blueprint
|
||||
|
||||
This module provides API endpoints for job status polling:
|
||||
- Get job status
|
||||
- Get job result
|
||||
|
||||
All endpoints require authentication.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.tasks.ai_tasks import get_job_status, get_job_result
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
not_found_response,
|
||||
error_response
|
||||
)
|
||||
from app.utils.auth import require_auth
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
jobs_bp = Blueprint('jobs', __name__)
|
||||
|
||||
|
||||
@jobs_bp.route('/api/v1/jobs/<job_id>/status', methods=['GET'])
|
||||
@require_auth
|
||||
def job_status(job_id: str):
|
||||
"""
|
||||
Get the status of an AI job.
|
||||
|
||||
Args:
|
||||
job_id: The job ID returned from action submission
|
||||
|
||||
Returns:
|
||||
JSON response with job status and result if completed
|
||||
"""
|
||||
try:
|
||||
status_info = get_job_status(job_id)
|
||||
|
||||
if not status_info or status_info.get('status') == 'not_found':
|
||||
return not_found_response(f"Job not found: {job_id}")
|
||||
|
||||
# If completed, include the result
|
||||
if status_info.get('status') == 'completed':
|
||||
result = get_job_result(job_id)
|
||||
if result:
|
||||
status_info['dm_response'] = result.get('dm_response', '')
|
||||
status_info['tokens_used'] = result.get('tokens_used', 0)
|
||||
status_info['model'] = result.get('model', '')
|
||||
|
||||
logger.debug("Job status retrieved",
|
||||
job_id=job_id,
|
||||
status=status_info.get('status'))
|
||||
|
||||
return success_response(status_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get job status",
|
||||
job_id=job_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="JOB_STATUS_ERROR",
|
||||
message="Failed to get job status"
|
||||
)
|
||||
429
api/app/api/npcs.py
Normal file
429
api/app/api/npcs.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
NPC API Blueprint
|
||||
|
||||
This module provides API endpoints for NPC interactions:
|
||||
- Get NPC details
|
||||
- Talk to NPC (queues AI dialogue generation)
|
||||
- Get NPCs at location
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
accepted_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
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
npcs_bp = Blueprint('npcs', __name__)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_npc_details(npc_id: str):
|
||||
"""
|
||||
Get NPC details with knowledge filtered by character interaction state.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID to get details for
|
||||
|
||||
Query params:
|
||||
character_id: Optional character ID for filtering revealed secrets
|
||||
|
||||
Returns:
|
||||
JSON response with NPC details
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
character_id = request.args.get('character_id')
|
||||
|
||||
# Load NPC
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
npc_data = npc.to_dict()
|
||||
|
||||
# Filter knowledge based on character interaction state
|
||||
if character_id:
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id, user.id)
|
||||
|
||||
if character:
|
||||
# Get revealed secrets based on conditions
|
||||
revealed = character_service.check_npc_secret_conditions(character, npc)
|
||||
|
||||
# Build available knowledge (public + revealed)
|
||||
available_knowledge = []
|
||||
if npc.knowledge:
|
||||
available_knowledge.extend(npc.knowledge.public)
|
||||
available_knowledge.extend(revealed)
|
||||
|
||||
npc_data["available_knowledge"] = available_knowledge
|
||||
|
||||
# Remove secret knowledge from response
|
||||
if npc_data.get("knowledge"):
|
||||
npc_data["knowledge"]["secret"] = []
|
||||
npc_data["knowledge"]["will_share_if"] = []
|
||||
|
||||
# Add interaction summary
|
||||
interaction = character.npc_interactions.get(npc_id, {})
|
||||
npc_data["interaction_summary"] = {
|
||||
"interaction_count": interaction.get("interaction_count", 0),
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"first_met": interaction.get("first_met"),
|
||||
}
|
||||
|
||||
except CharacterNotFound:
|
||||
logger.debug("Character not found for NPC filter", character_id=character_id)
|
||||
|
||||
return success_response(npc_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get NPC details",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get NPC", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/talk', methods=['POST'])
|
||||
@require_auth
|
||||
def talk_to_npc(npc_id: str):
|
||||
"""
|
||||
Initiate conversation with an NPC.
|
||||
|
||||
Validates NPC is at current location, updates interaction state,
|
||||
and queues AI dialogue generation task.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID to talk to
|
||||
|
||||
Request body:
|
||||
session_id: Active session ID
|
||||
topic: Conversation topic/opener (default: "greeting")
|
||||
player_response: What the player says to the NPC (overrides topic if provided)
|
||||
|
||||
Returns:
|
||||
JSON response with job_id for polling result
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
session_id = data.get('session_id')
|
||||
# player_response overrides topic for bidirectional dialogue
|
||||
player_response = data.get('player_response')
|
||||
topic = player_response if player_response else data.get('topic', 'greeting')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id is required")
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Load NPC
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Validate NPC is at current location
|
||||
if npc.location_id != session.game_state.current_location:
|
||||
logger.warning("NPC not at current location",
|
||||
npc_id=npc_id,
|
||||
npc_location=npc.location_id,
|
||||
current_location=session.game_state.current_location)
|
||||
return error_response("NPC is not at your current location", 400)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
# Get or create interaction state
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
interaction = character.npc_interactions.get(npc_id, {})
|
||||
|
||||
if not interaction:
|
||||
# First meeting
|
||||
interaction = {
|
||||
"npc_id": npc_id,
|
||||
"first_met": now,
|
||||
"last_interaction": now,
|
||||
"interaction_count": 1,
|
||||
"revealed_secrets": [],
|
||||
"relationship_level": 50,
|
||||
"custom_flags": {},
|
||||
}
|
||||
else:
|
||||
# Update existing interaction
|
||||
interaction["last_interaction"] = now
|
||||
interaction["interaction_count"] = interaction.get("interaction_count", 0) + 1
|
||||
|
||||
# Check for newly revealed secrets
|
||||
revealed = character_service.check_npc_secret_conditions(character, npc)
|
||||
|
||||
# Update character with new interaction state
|
||||
character_service.update_npc_interaction(
|
||||
character.character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
interaction
|
||||
)
|
||||
|
||||
# Build NPC knowledge for AI context
|
||||
npc_knowledge = []
|
||||
if npc.knowledge:
|
||||
npc_knowledge.extend(npc.knowledge.public)
|
||||
npc_knowledge.extend(revealed)
|
||||
|
||||
# Get previous dialogue history for context (last 3 exchanges)
|
||||
previous_dialogue = character_service.get_npc_dialogue_history(
|
||||
character.character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
limit=3
|
||||
)
|
||||
|
||||
# Prepare AI context
|
||||
task_context = {
|
||||
"session_id": session_id,
|
||||
"character_id": character.character_id,
|
||||
"character": character.to_story_dict(),
|
||||
"npc": npc.to_story_dict(),
|
||||
"npc_full": npc.to_dict(), # Full NPC data for reference
|
||||
"conversation_topic": topic,
|
||||
"game_state": session.game_state.to_dict(),
|
||||
"npc_knowledge": npc_knowledge,
|
||||
"revealed_secrets": revealed,
|
||||
"interaction_count": interaction["interaction_count"],
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"previous_dialogue": previous_dialogue, # Pass conversation history
|
||||
}
|
||||
|
||||
# Enqueue AI task
|
||||
result = enqueue_ai_task(
|
||||
task_type=TaskType.NPC_DIALOGUE,
|
||||
user_id=user.id,
|
||||
context=task_context,
|
||||
priority="normal",
|
||||
session_id=session_id,
|
||||
character_id=character.character_id
|
||||
)
|
||||
|
||||
logger.info("NPC dialogue task queued",
|
||||
user_id=user.id,
|
||||
npc_id=npc_id,
|
||||
job_id=result.get('job_id'),
|
||||
interaction_count=interaction["interaction_count"])
|
||||
|
||||
return accepted_response({
|
||||
"job_id": result.get('job_id'),
|
||||
"status": "queued",
|
||||
"message": f"Starting conversation with {npc.name}...",
|
||||
"npc_name": npc.name,
|
||||
"npc_role": npc.role,
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to talk to NPC",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to start conversation", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/at-location/<location_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_npcs_at_location(location_id: str):
|
||||
"""
|
||||
Get all NPCs at a specific location.
|
||||
|
||||
Path params:
|
||||
location_id: Location ID to get NPCs for
|
||||
|
||||
Returns:
|
||||
JSON response with list of NPCs at location
|
||||
"""
|
||||
try:
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
npcs_list = []
|
||||
for npc in npcs:
|
||||
npcs_list.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
"tags": npc.tags,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location_id": location_id,
|
||||
"npcs": npcs_list,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get NPCs at location",
|
||||
location_id=location_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get NPCs", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/relationship', methods=['POST'])
|
||||
@require_auth
|
||||
def adjust_npc_relationship(npc_id: str):
|
||||
"""
|
||||
Adjust relationship level with an NPC.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID
|
||||
|
||||
Request body:
|
||||
character_id: Character ID
|
||||
adjustment: Amount to add/subtract (can be negative)
|
||||
|
||||
Returns:
|
||||
JSON response with updated relationship level
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
character_id = data.get('character_id')
|
||||
adjustment = data.get('adjustment', 0)
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response("character_id is required")
|
||||
|
||||
if not isinstance(adjustment, int):
|
||||
return validation_error_response("adjustment must be an integer")
|
||||
|
||||
# Validate NPC exists
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Adjust relationship
|
||||
character_service = get_character_service()
|
||||
character = character_service.adjust_npc_relationship(
|
||||
character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
adjustment
|
||||
)
|
||||
|
||||
new_level = character.npc_interactions.get(npc_id, {}).get("relationship_level", 50)
|
||||
|
||||
logger.info("NPC relationship adjusted",
|
||||
npc_id=npc_id,
|
||||
character_id=character_id,
|
||||
adjustment=adjustment,
|
||||
new_level=new_level)
|
||||
|
||||
return success_response({
|
||||
"npc_id": npc_id,
|
||||
"relationship_level": new_level,
|
||||
})
|
||||
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to adjust NPC relationship",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to adjust relationship", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/flag', methods=['POST'])
|
||||
@require_auth
|
||||
def set_npc_flag(npc_id: str):
|
||||
"""
|
||||
Set a custom flag on NPC interaction (e.g., "helped_with_rats": true).
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID
|
||||
|
||||
Request body:
|
||||
character_id: Character ID
|
||||
flag_name: Name of the flag
|
||||
flag_value: Value to set
|
||||
|
||||
Returns:
|
||||
JSON response confirming flag was set
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
character_id = data.get('character_id')
|
||||
flag_name = data.get('flag_name')
|
||||
flag_value = data.get('flag_value')
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response("character_id is required")
|
||||
if not flag_name:
|
||||
return validation_error_response("flag_name is required")
|
||||
|
||||
# Validate NPC exists
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Set flag
|
||||
character_service = get_character_service()
|
||||
character_service.set_npc_custom_flag(
|
||||
character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
flag_name,
|
||||
flag_value
|
||||
)
|
||||
|
||||
logger.info("NPC flag set",
|
||||
npc_id=npc_id,
|
||||
character_id=character_id,
|
||||
flag_name=flag_name)
|
||||
|
||||
return success_response({
|
||||
"npc_id": npc_id,
|
||||
"flag_name": flag_name,
|
||||
"flag_value": flag_value,
|
||||
})
|
||||
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to set NPC flag",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to set flag", 500)
|
||||
604
api/app/api/sessions.py
Normal file
604
api/app/api/sessions.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
Sessions API Blueprint
|
||||
|
||||
This module provides API endpoints for story session management:
|
||||
- Create new solo session
|
||||
- Get session state
|
||||
- Take action (async AI processing)
|
||||
- Get conversation history
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
|
||||
from app.services.session_service import (
|
||||
get_session_service,
|
||||
SessionNotFound,
|
||||
SessionLimitExceeded,
|
||||
SessionValidationError
|
||||
)
|
||||
from app.services.character_service import CharacterNotFound, get_character_service
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.services.action_prompt_loader import ActionPromptLoader, ActionPromptNotFoundError
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType, get_job_status, get_job_result
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.game_logic.dice import SkillType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
accepted_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response,
|
||||
rate_limit_exceeded_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
sessions_bp = Blueprint('sessions', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_character_id(character_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate character ID format.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not character_id:
|
||||
return False, "Character ID is required"
|
||||
|
||||
if not isinstance(character_id, str):
|
||||
return False, "Character ID must be a string"
|
||||
|
||||
if len(character_id) > 100:
|
||||
return False, "Character ID is too long"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_action_request(data: dict, user_tier: UserTier) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate action request data.
|
||||
|
||||
Args:
|
||||
data: Request JSON data
|
||||
user_tier: User's subscription tier
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
action_type = data.get('action_type')
|
||||
|
||||
if action_type != 'button':
|
||||
return False, "action_type must be 'button'"
|
||||
|
||||
if not data.get('prompt_id'):
|
||||
return False, "prompt_id is required for button actions"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_user_tier_from_user(user) -> UserTier:
|
||||
"""
|
||||
Get UserTier enum from user object.
|
||||
|
||||
Args:
|
||||
user: User object from auth
|
||||
|
||||
Returns:
|
||||
UserTier enum value
|
||||
"""
|
||||
# Map user tier string to UserTier enum
|
||||
tier_mapping = {
|
||||
'free': UserTier.FREE,
|
||||
'basic': UserTier.BASIC,
|
||||
'premium': UserTier.PREMIUM,
|
||||
'elite': UserTier.ELITE
|
||||
}
|
||||
|
||||
user_tier_str = getattr(user, 'tier', 'free').lower()
|
||||
return tier_mapping.get(user_tier_str, UserTier.FREE)
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions', methods=['GET'])
|
||||
@require_auth
|
||||
def list_sessions():
|
||||
"""
|
||||
List user's active game sessions.
|
||||
|
||||
Returns all active sessions for the authenticated user with basic session info.
|
||||
|
||||
Returns:
|
||||
JSON response with list of sessions
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
session_service = get_session_service()
|
||||
|
||||
# Get user's active sessions
|
||||
sessions = session_service.get_user_sessions(user_id, active_only=True)
|
||||
|
||||
# Build response with basic session info
|
||||
sessions_list = []
|
||||
for session in sessions:
|
||||
sessions_list.append({
|
||||
'session_id': session.session_id,
|
||||
'character_id': session.solo_character_id,
|
||||
'turn_number': session.turn_number,
|
||||
'status': session.status.value,
|
||||
'created_at': session.created_at,
|
||||
'last_activity': session.last_activity,
|
||||
'game_state': {
|
||||
'current_location': session.game_state.current_location,
|
||||
'location_type': session.game_state.location_type.value
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Sessions listed successfully",
|
||||
user_id=user_id,
|
||||
count=len(sessions_list))
|
||||
|
||||
return success_response(sessions_list)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list sessions", error=str(e))
|
||||
return error_response(f"Failed to list sessions: {str(e)}", 500)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions', methods=['POST'])
|
||||
@require_auth
|
||||
def create_session():
|
||||
"""
|
||||
Create a new solo game session.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"character_id": "char_456"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: Session created with initial state
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
404: Character not found
|
||||
409: Session limit exceeded
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Creating new session")
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Parse and validate request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return validation_error_response("Request body is required")
|
||||
|
||||
character_id = data.get('character_id')
|
||||
is_valid, error_msg = validate_character_id(character_id)
|
||||
if not is_valid:
|
||||
return validation_error_response(error_msg)
|
||||
|
||||
# Create session
|
||||
session_service = get_session_service()
|
||||
session = session_service.create_solo_session(
|
||||
user_id=user_id,
|
||||
character_id=character_id
|
||||
)
|
||||
|
||||
logger.info("Session created successfully",
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
# Return session data
|
||||
return created_response({
|
||||
"session_id": session.session_id,
|
||||
"character_id": session.solo_character_id,
|
||||
"turn_number": session.turn_number,
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
}
|
||||
})
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for session creation",
|
||||
error=str(e))
|
||||
return not_found_response("Character not found")
|
||||
|
||||
except SessionLimitExceeded as e:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id if 'user_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
status=409,
|
||||
code="SESSION_LIMIT_EXCEEDED",
|
||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create session",
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_CREATE_ERROR",
|
||||
message="Failed to create session"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>/action', methods=['POST'])
|
||||
@require_auth
|
||||
def take_action(session_id: str):
|
||||
"""
|
||||
Submit an action for AI processing (async).
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"action_type": "button",
|
||||
"prompt_id": "ask_locals"
|
||||
}
|
||||
|
||||
Returns:
|
||||
202: Action queued for processing
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Action not available for tier/location
|
||||
404: Session not found
|
||||
429: Rate limit exceeded
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Processing action request", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Verify session ownership and get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Parse and validate request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return validation_error_response("Request body is required")
|
||||
|
||||
is_valid, error_msg = validate_action_request(data, user_tier)
|
||||
if not is_valid:
|
||||
return validation_error_response(error_msg)
|
||||
|
||||
# Check rate limit
|
||||
rate_limiter = RateLimiterService()
|
||||
|
||||
try:
|
||||
rate_limiter.check_rate_limit(user_id, user_tier)
|
||||
except RateLimitExceeded as e:
|
||||
logger.warning("Rate limit exceeded",
|
||||
user_id=user_id,
|
||||
tier=user_tier.value)
|
||||
return rate_limit_exceeded_response(
|
||||
message=f"Daily turn limit reached ({e.limit} turns). Resets at {e.reset_time.strftime('%H:%M UTC')}"
|
||||
)
|
||||
|
||||
# Build action context for AI task
|
||||
prompt_id = data.get('prompt_id')
|
||||
|
||||
# Validate prompt exists and is available
|
||||
loader = ActionPromptLoader()
|
||||
try:
|
||||
action_prompt = loader.get_action_by_id(prompt_id)
|
||||
except ActionPromptNotFoundError:
|
||||
return validation_error_response(f"Invalid prompt_id: {prompt_id}")
|
||||
|
||||
# Check if action is available for user's tier and location
|
||||
location_type = session.game_state.location_type
|
||||
if not action_prompt.is_available(user_tier, location_type):
|
||||
return error_response(
|
||||
status=403,
|
||||
code="ACTION_NOT_AVAILABLE",
|
||||
message="This action is not available for your tier or location"
|
||||
)
|
||||
|
||||
action_text = action_prompt.display_text
|
||||
dm_prompt_template = action_prompt.dm_prompt_template
|
||||
|
||||
# Fetch character data for AI context
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user_id)
|
||||
if not character:
|
||||
return not_found_response(f"Character {session.solo_character_id} not found")
|
||||
|
||||
# Perform dice check if action requires it
|
||||
check_outcome = None
|
||||
if action_prompt.requires_check:
|
||||
check_req = action_prompt.requires_check
|
||||
location_type_str = session.game_state.location_type.value if hasattr(session.game_state.location_type, 'value') else str(session.game_state.location_type)
|
||||
|
||||
# Get DC from difficulty
|
||||
dc = outcome_service.get_dc_for_difficulty(check_req.difficulty)
|
||||
|
||||
if check_req.check_type == "search":
|
||||
# Search check - uses perception and returns items/gold
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type_str,
|
||||
dc=dc
|
||||
)
|
||||
check_outcome = outcome.to_dict()
|
||||
|
||||
logger.info(
|
||||
"Search check performed",
|
||||
character_id=character.character_id,
|
||||
success=outcome.check_result.success,
|
||||
items_found=len(outcome.items_found),
|
||||
gold_found=outcome.gold_found
|
||||
)
|
||||
elif check_req.check_type == "skill" and check_req.skill:
|
||||
# Skill check - generic skill vs DC
|
||||
try:
|
||||
skill_type = SkillType[check_req.skill.upper()]
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc
|
||||
)
|
||||
check_outcome = outcome.to_dict()
|
||||
|
||||
logger.info(
|
||||
"Skill check performed",
|
||||
character_id=character.character_id,
|
||||
skill=check_req.skill,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Invalid skill type in action prompt",
|
||||
prompt_id=action_prompt.prompt_id,
|
||||
skill=check_req.skill,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Queue AI task
|
||||
# Use trimmed character data for AI prompts (reduces tokens, focuses on story-relevant info)
|
||||
task_context = {
|
||||
"session_id": session_id,
|
||||
"character_id": session.solo_character_id,
|
||||
"action": action_text,
|
||||
"prompt_id": prompt_id,
|
||||
"dm_prompt_template": dm_prompt_template,
|
||||
"character": character.to_story_dict(),
|
||||
"game_state": session.game_state.to_dict(),
|
||||
"turn_number": session.turn_number,
|
||||
"conversation_history": [entry.to_dict() for entry in session.conversation_history],
|
||||
"world_context": None, # TODO: Add world context source when available
|
||||
"check_outcome": check_outcome # Dice check result for predetermined outcomes
|
||||
}
|
||||
|
||||
result = enqueue_ai_task(
|
||||
task_type=TaskType.NARRATIVE,
|
||||
user_id=user_id,
|
||||
context=task_context,
|
||||
priority="normal",
|
||||
session_id=session_id,
|
||||
character_id=session.solo_character_id
|
||||
)
|
||||
|
||||
# Increment rate limit counter
|
||||
rate_limiter.increment_usage(user_id)
|
||||
|
||||
logger.info("Action queued for processing",
|
||||
session_id=session_id,
|
||||
job_id=result.get('job_id'),
|
||||
prompt_id=prompt_id)
|
||||
|
||||
return accepted_response({
|
||||
"job_id": result.get('job_id'),
|
||||
"status": result.get('status', 'queued'),
|
||||
"message": "Your action is being processed..."
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for action",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to process action",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="ACTION_PROCESS_ERROR",
|
||||
message="Failed to process action"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_session_state(session_id: str):
|
||||
"""
|
||||
Get current session state with available actions.
|
||||
|
||||
Returns:
|
||||
200: Session state
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting session state", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Get available actions based on location and tier
|
||||
loader = ActionPromptLoader()
|
||||
location_type = session.game_state.location_type
|
||||
|
||||
available_actions = []
|
||||
for action in loader.get_available_actions(user_tier, location_type):
|
||||
available_actions.append({
|
||||
"prompt_id": action.prompt_id,
|
||||
"display_text": action.display_text,
|
||||
"description": action.description,
|
||||
"category": action.category.value
|
||||
})
|
||||
|
||||
logger.debug("Session state retrieved",
|
||||
session_id=session_id,
|
||||
turn_number=session.turn_number)
|
||||
|
||||
return success_response({
|
||||
"session_id": session.session_id,
|
||||
"character_id": session.get_character_id(),
|
||||
"turn_number": session.turn_number,
|
||||
"status": session.status.value,
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
},
|
||||
"available_actions": available_actions
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get session state",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_STATE_ERROR",
|
||||
message="Failed to get session state"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>/history', methods=['GET'])
|
||||
@require_auth
|
||||
def get_history(session_id: str):
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Query Parameters:
|
||||
limit: Number of entries to return (default 20)
|
||||
offset: Number of entries to skip (default 0)
|
||||
|
||||
Returns:
|
||||
200: Paginated conversation history
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting conversation history", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Get pagination params
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
|
||||
# Clamp values
|
||||
limit = max(1, min(limit, 100)) # 1-100
|
||||
offset = max(0, offset)
|
||||
|
||||
# Verify session ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Get total history
|
||||
total_history = session.conversation_history
|
||||
total_turns = len(total_history)
|
||||
|
||||
# Apply pagination (from beginning)
|
||||
paginated_history = total_history[offset:offset + limit]
|
||||
|
||||
# Format history entries
|
||||
history_data = []
|
||||
for entry in paginated_history:
|
||||
# Handle timestamp - could be datetime object or already a string
|
||||
timestamp = None
|
||||
if hasattr(entry, 'timestamp') and entry.timestamp:
|
||||
if isinstance(entry.timestamp, str):
|
||||
timestamp = entry.timestamp
|
||||
else:
|
||||
timestamp = entry.timestamp.isoformat()
|
||||
|
||||
history_data.append({
|
||||
"turn": entry.turn,
|
||||
"action": entry.action,
|
||||
"dm_response": entry.dm_response,
|
||||
"timestamp": timestamp
|
||||
})
|
||||
|
||||
logger.debug("Conversation history retrieved",
|
||||
session_id=session_id,
|
||||
total=total_turns,
|
||||
returned=len(history_data))
|
||||
|
||||
return success_response({
|
||||
"total_turns": total_turns,
|
||||
"history": history_data,
|
||||
"pagination": {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total_turns
|
||||
}
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for history",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation history",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="HISTORY_ERROR",
|
||||
message="Failed to get conversation history"
|
||||
)
|
||||
306
api/app/api/travel.py
Normal file
306
api/app/api/travel.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Travel API Blueprint
|
||||
|
||||
This module provides API endpoints for location-based travel:
|
||||
- Get available destinations
|
||||
- Travel to a location
|
||||
- Get current location details
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
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
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
travel_bp = Blueprint('travel', __name__)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/available', methods=['GET'])
|
||||
@require_auth
|
||||
def get_available_destinations():
|
||||
"""
|
||||
Get all locations the character can travel to.
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID
|
||||
|
||||
Returns:
|
||||
JSON response with list of available destinations
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
session_id = request.args.get('session_id')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id query parameter is required")
|
||||
|
||||
# Get session and verify ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Get character for discovered locations
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
# Load location details for each discovered location
|
||||
location_loader = get_location_loader()
|
||||
destinations = []
|
||||
|
||||
for loc_id in character.discovered_locations:
|
||||
# Skip current location
|
||||
if loc_id == session.game_state.current_location:
|
||||
continue
|
||||
|
||||
location = location_loader.load_location(loc_id)
|
||||
if location:
|
||||
destinations.append({
|
||||
"location_id": location.location_id,
|
||||
"name": location.name,
|
||||
"location_type": location.location_type.value,
|
||||
"region_id": location.region_id,
|
||||
"description": location.description[:200] + "..." if len(location.description) > 200 else location.description,
|
||||
})
|
||||
|
||||
logger.info("Retrieved available destinations",
|
||||
user_id=user.id,
|
||||
session_id=session_id,
|
||||
destination_count=len(destinations))
|
||||
|
||||
return success_response({
|
||||
"current_location": session.game_state.current_location,
|
||||
"destinations": destinations
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to get available destinations",
|
||||
error=str(e))
|
||||
return error_response("Failed to get destinations", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel', methods=['POST'])
|
||||
@require_auth
|
||||
def travel_to_location():
|
||||
"""
|
||||
Travel to a discovered location.
|
||||
|
||||
Request body:
|
||||
session_id: Active session ID
|
||||
location_id: Target location ID
|
||||
|
||||
Returns:
|
||||
JSON response with new location details and NPCs present
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
session_id = data.get('session_id')
|
||||
location_id = data.get('location_id')
|
||||
|
||||
# Validate required fields
|
||||
if not session_id:
|
||||
return validation_error_response("session_id is required")
|
||||
if not location_id:
|
||||
return validation_error_response("location_id is required")
|
||||
|
||||
# Get session and verify ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Get character and verify location is discovered
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
if location_id not in character.discovered_locations:
|
||||
logger.warning("Attempted travel to undiscovered location",
|
||||
user_id=user.id,
|
||||
character_id=character.character_id,
|
||||
location_id=location_id)
|
||||
return error_response("Location not discovered", 403)
|
||||
|
||||
# Load location details
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(location_id)
|
||||
|
||||
if not location:
|
||||
logger.error("Location not found in data files",
|
||||
location_id=location_id)
|
||||
return not_found_response("Location not found")
|
||||
|
||||
# Update session with new location
|
||||
session = session_service.update_location(
|
||||
session_id,
|
||||
location_id,
|
||||
location.location_type
|
||||
)
|
||||
|
||||
# Get NPCs at new location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
# Build NPC summary list
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
logger.info("Character traveled to location",
|
||||
user_id=user.id,
|
||||
session_id=session_id,
|
||||
location_id=location_id)
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
"game_state": session.game_state.to_dict(),
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to travel to location",
|
||||
error=str(e))
|
||||
return error_response("Failed to travel", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/location/<location_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_location_details(location_id: str):
|
||||
"""
|
||||
Get details about a specific location.
|
||||
|
||||
Path params:
|
||||
location_id: Location ID to get details for
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID (optional, for context)
|
||||
|
||||
Returns:
|
||||
JSON response with location details and NPCs
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Load location
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(location_id)
|
||||
|
||||
if not location:
|
||||
return not_found_response("Location not found")
|
||||
|
||||
# Get NPCs at location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get location details",
|
||||
location_id=location_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get location", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/current', methods=['GET'])
|
||||
@require_auth
|
||||
def get_current_location():
|
||||
"""
|
||||
Get details about the current location in a session.
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID
|
||||
|
||||
Returns:
|
||||
JSON response with current location details and NPCs
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
session_id = request.args.get('session_id')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id query parameter is required")
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
current_location_id = session.game_state.current_location
|
||||
|
||||
# Load location
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(current_location_id)
|
||||
|
||||
if not location:
|
||||
# Location not in data files - return basic info from session
|
||||
return success_response({
|
||||
"location": {
|
||||
"location_id": current_location_id,
|
||||
"name": current_location_id,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
},
|
||||
"npcs_present": [],
|
||||
})
|
||||
|
||||
# Get NPCs at location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(current_location_id)
|
||||
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to get current location",
|
||||
error=str(e))
|
||||
return error_response("Failed to get current location", 500)
|
||||
319
api/app/config.py
Normal file
319
api/app/config.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Configuration loader for Code of Conquest.
|
||||
|
||||
Loads configuration from YAML files and environment variables,
|
||||
providing typed access to all configuration values.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""Application configuration."""
|
||||
name: str
|
||||
version: str
|
||||
environment: str
|
||||
debug: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Server configuration."""
|
||||
host: str
|
||||
port: int
|
||||
workers: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisConfig:
|
||||
"""Redis configuration."""
|
||||
host: str
|
||||
port: int
|
||||
db: int
|
||||
max_connections: int
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Generate Redis URL."""
|
||||
return f"redis://{self.host}:{self.port}/{self.db}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RQConfig:
|
||||
"""RQ (Redis Queue) configuration."""
|
||||
queues: List[str]
|
||||
worker_timeout: int
|
||||
job_timeout: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIModelConfig:
|
||||
"""AI model configuration."""
|
||||
provider: str
|
||||
model: str
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIConfig:
|
||||
"""AI service configuration."""
|
||||
timeout: int
|
||||
max_retries: int
|
||||
cost_alert_threshold: float
|
||||
models: Dict[str, AIModelConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitTier:
|
||||
"""Rate limit configuration for a subscription tier."""
|
||||
requests_per_minute: int
|
||||
ai_calls_per_day: int
|
||||
custom_actions_per_day: int # -1 for unlimited
|
||||
custom_action_char_limit: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitingConfig:
|
||||
"""Rate limiting configuration."""
|
||||
enabled: bool
|
||||
storage_url: str
|
||||
tiers: Dict[str, RateLimitTier] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Authentication configuration."""
|
||||
cookie_name: str
|
||||
duration_normal: int
|
||||
duration_remember_me: int
|
||||
http_only: bool
|
||||
secure: bool
|
||||
same_site: str
|
||||
path: str
|
||||
password_min_length: int
|
||||
password_require_uppercase: bool
|
||||
password_require_lowercase: bool
|
||||
password_require_number: bool
|
||||
password_require_special: bool
|
||||
name_min_length: int
|
||||
name_max_length: int
|
||||
email_max_length: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionConfig:
|
||||
"""Game session configuration."""
|
||||
timeout_minutes: int
|
||||
auto_save_interval: int
|
||||
min_players: int
|
||||
max_players_by_tier: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketplaceConfig:
|
||||
"""Marketplace configuration."""
|
||||
auction_check_interval: int
|
||||
max_listings_by_tier: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CORSConfig:
|
||||
"""CORS configuration."""
|
||||
origins: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
"""Logging configuration."""
|
||||
level: str
|
||||
format: str
|
||||
handlers: List[str]
|
||||
file_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""
|
||||
Main configuration container.
|
||||
|
||||
Loads configuration from YAML file based on environment,
|
||||
with overrides from environment variables.
|
||||
"""
|
||||
app: AppConfig
|
||||
server: ServerConfig
|
||||
redis: RedisConfig
|
||||
rq: RQConfig
|
||||
ai: AIConfig
|
||||
rate_limiting: RateLimitingConfig
|
||||
auth: AuthConfig
|
||||
session: SessionConfig
|
||||
marketplace: MarketplaceConfig
|
||||
cors: CORSConfig
|
||||
logging: LoggingConfig
|
||||
|
||||
# Environment variables (loaded from .env)
|
||||
secret_key: str = ""
|
||||
appwrite_endpoint: str = ""
|
||||
appwrite_project_id: str = ""
|
||||
appwrite_api_key: str = ""
|
||||
appwrite_database_id: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
replicate_api_token: str = ""
|
||||
|
||||
@classmethod
|
||||
def load(cls, environment: Optional[str] = None) -> 'Config':
|
||||
"""
|
||||
Load configuration from YAML file and environment variables.
|
||||
|
||||
Args:
|
||||
environment: Environment name (development, production, etc.).
|
||||
If not provided, uses FLASK_ENV from environment.
|
||||
|
||||
Returns:
|
||||
Config: Loaded configuration object.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file not found.
|
||||
ValueError: If required environment variables missing.
|
||||
"""
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Determine environment
|
||||
if environment is None:
|
||||
environment = os.getenv('FLASK_ENV', 'development')
|
||||
|
||||
# Load YAML configuration
|
||||
config_path = os.path.join('config', f'{environment}.yaml')
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Configuration file not found: {config_path}"
|
||||
)
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
|
||||
# Parse configuration sections
|
||||
app_config = AppConfig(**config_data['app'])
|
||||
server_config = ServerConfig(**config_data['server'])
|
||||
redis_config = RedisConfig(**config_data['redis'])
|
||||
rq_config = RQConfig(**config_data['rq'])
|
||||
|
||||
# Parse AI models
|
||||
ai_models = {}
|
||||
for tier, model_data in config_data['ai']['models'].items():
|
||||
ai_models[tier] = AIModelConfig(**model_data)
|
||||
|
||||
ai_config = AIConfig(
|
||||
timeout=config_data['ai']['timeout'],
|
||||
max_retries=config_data['ai']['max_retries'],
|
||||
cost_alert_threshold=config_data['ai']['cost_alert_threshold'],
|
||||
models=ai_models
|
||||
)
|
||||
|
||||
# Parse rate limiting tiers
|
||||
rate_limit_tiers = {}
|
||||
for tier, tier_data in config_data['rate_limiting']['tiers'].items():
|
||||
rate_limit_tiers[tier] = RateLimitTier(**tier_data)
|
||||
|
||||
rate_limiting_config = RateLimitingConfig(
|
||||
enabled=config_data['rate_limiting']['enabled'],
|
||||
storage_url=config_data['rate_limiting']['storage_url'],
|
||||
tiers=rate_limit_tiers
|
||||
)
|
||||
|
||||
auth_config = AuthConfig(**config_data['auth'])
|
||||
session_config = SessionConfig(**config_data['session'])
|
||||
marketplace_config = MarketplaceConfig(**config_data['marketplace'])
|
||||
cors_config = CORSConfig(**config_data['cors'])
|
||||
logging_config = LoggingConfig(**config_data['logging'])
|
||||
|
||||
# Load environment variables (secrets)
|
||||
secret_key = os.getenv('SECRET_KEY')
|
||||
if not secret_key:
|
||||
raise ValueError("SECRET_KEY environment variable is required")
|
||||
|
||||
appwrite_endpoint = os.getenv('APPWRITE_ENDPOINT', '')
|
||||
appwrite_project_id = os.getenv('APPWRITE_PROJECT_ID', '')
|
||||
appwrite_api_key = os.getenv('APPWRITE_API_KEY', '')
|
||||
appwrite_database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
anthropic_api_key = os.getenv('ANTHROPIC_API_KEY', '')
|
||||
replicate_api_token = os.getenv('REPLICATE_API_TOKEN', '')
|
||||
|
||||
# Create and return config object
|
||||
return cls(
|
||||
app=app_config,
|
||||
server=server_config,
|
||||
redis=redis_config,
|
||||
rq=rq_config,
|
||||
ai=ai_config,
|
||||
rate_limiting=rate_limiting_config,
|
||||
auth=auth_config,
|
||||
session=session_config,
|
||||
marketplace=marketplace_config,
|
||||
cors=cors_config,
|
||||
logging=logging_config,
|
||||
secret_key=secret_key,
|
||||
appwrite_endpoint=appwrite_endpoint,
|
||||
appwrite_project_id=appwrite_project_id,
|
||||
appwrite_api_key=appwrite_api_key,
|
||||
appwrite_database_id=appwrite_database_id,
|
||||
anthropic_api_key=anthropic_api_key,
|
||||
replicate_api_token=replicate_api_token
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate configuration values.
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid.
|
||||
"""
|
||||
# Validate AI API keys if needed
|
||||
if self.app.environment == 'production':
|
||||
if not self.anthropic_api_key:
|
||||
raise ValueError(
|
||||
"ANTHROPIC_API_KEY required in production environment"
|
||||
)
|
||||
if not self.appwrite_endpoint or not self.appwrite_project_id:
|
||||
raise ValueError(
|
||||
"Appwrite configuration required in production environment"
|
||||
)
|
||||
|
||||
# Validate logging level
|
||||
valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
if self.logging.level not in valid_log_levels:
|
||||
raise ValueError(
|
||||
f"Invalid log level: {self.logging.level}. "
|
||||
f"Must be one of {valid_log_levels}"
|
||||
)
|
||||
|
||||
|
||||
# Global config instance (loaded lazily)
|
||||
_config: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config(environment: Optional[str] = None) -> Config:
|
||||
"""
|
||||
Get the global configuration instance.
|
||||
|
||||
Args:
|
||||
environment: Optional environment override.
|
||||
|
||||
Returns:
|
||||
Config: Configuration object.
|
||||
"""
|
||||
global _config
|
||||
|
||||
if _config is None or environment is not None:
|
||||
_config = Config.load(environment)
|
||||
_config.validate()
|
||||
|
||||
return _config
|
||||
141
api/app/data/abilities/README.md
Normal file
141
api/app/data/abilities/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Ability Configuration Files
|
||||
|
||||
This directory contains YAML configuration files that define all abilities in the game.
|
||||
|
||||
## Format
|
||||
|
||||
Each ability is defined in a separate `.yaml` file with the following structure:
|
||||
|
||||
```yaml
|
||||
ability_id: "unique_identifier"
|
||||
name: "Display Name"
|
||||
description: "What the ability does"
|
||||
ability_type: "attack|spell|skill|item_use|defend"
|
||||
base_power: 0 # Base damage or healing
|
||||
damage_type: "physical|fire|ice|lightning|holy|shadow|poison"
|
||||
scaling_stat: "strength|dexterity|constitution|intelligence|wisdom|charisma"
|
||||
scaling_factor: 0.5 # Multiplier for scaling stat
|
||||
mana_cost: 0 # MP required to use
|
||||
cooldown: 0 # Turns before can be used again
|
||||
is_aoe: false # Whether it affects multiple targets
|
||||
target_count: 1 # Number of targets (0 = all enemies)
|
||||
effects_applied: [] # List of effects to apply on hit
|
||||
```
|
||||
|
||||
## Effect Format
|
||||
|
||||
Effects applied by abilities use this structure:
|
||||
|
||||
```yaml
|
||||
effects_applied:
|
||||
- effect_id: "unique_id"
|
||||
name: "Effect Name"
|
||||
effect_type: "buff|debuff|dot|hot|stun|shield"
|
||||
duration: 3 # Turns before expiration
|
||||
power: 5 # Damage/healing/modifier per turn
|
||||
stat_affected: "strength" # For buffs/debuffs only (null otherwise)
|
||||
stacks: 1 # Initial stack count
|
||||
max_stacks: 5 # Maximum stacks allowed
|
||||
source: "ability_id" # Which ability applied this
|
||||
```
|
||||
|
||||
## Effect Types
|
||||
|
||||
| Type | Power Usage | Example |
|
||||
|------|-------------|---------|
|
||||
| `buff` | Stat modifier (×stacks) | +5 strength per stack |
|
||||
| `debuff` | Stat modifier (×stacks) | -3 defense per stack |
|
||||
| `dot` | Damage per turn (×stacks) | 5 poison damage per turn |
|
||||
| `hot` | Healing per turn (×stacks) | 8 HP regeneration per turn |
|
||||
| `stun` | Not used | Prevents actions for duration |
|
||||
| `shield` | Shield strength (×stacks) | 50 damage absorption |
|
||||
|
||||
## Damage Calculation
|
||||
|
||||
Abilities calculate their final power using this formula:
|
||||
|
||||
```
|
||||
Final Power = base_power + (scaling_stat × scaling_factor)
|
||||
Minimum power is always 1
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- Fireball with 30 base_power, INT scaling 0.5, caster has 16 INT:
|
||||
- 30 + (16 × 0.5) = 38 power
|
||||
- Shield Bash with 10 base_power, STR scaling 0.5, caster has 20 STR:
|
||||
- 10 + (20 × 0.5) = 20 power
|
||||
|
||||
## Loading Abilities
|
||||
|
||||
Abilities are loaded via the `AbilityLoader` class:
|
||||
|
||||
```python
|
||||
from app.models.abilities import AbilityLoader
|
||||
|
||||
loader = AbilityLoader()
|
||||
fireball = loader.load_ability("fireball")
|
||||
power = fireball.calculate_power(caster_stats)
|
||||
```
|
||||
|
||||
## Example Abilities
|
||||
|
||||
### basic_attack.yaml
|
||||
- Simple physical attack
|
||||
- No mana cost or cooldown
|
||||
- Available to all characters
|
||||
|
||||
### fireball.yaml
|
||||
- Offensive spell
|
||||
- Deals fire damage + applies burning DoT
|
||||
- Costs 15 MP, no cooldown
|
||||
|
||||
### shield_bash.yaml
|
||||
- Vanguard class skill
|
||||
- Deals damage + stuns for 1 turn
|
||||
- Costs 5 MP, 2 turn cooldown
|
||||
|
||||
### heal.yaml
|
||||
- Luminary class spell
|
||||
- Restores health + applies regeneration HoT
|
||||
- Costs 10 MP, no cooldown
|
||||
|
||||
## Creating New Abilities
|
||||
|
||||
1. Create a new `.yaml` file in this directory
|
||||
2. Follow the format above
|
||||
3. Set appropriate values for your ability
|
||||
4. Ability will be automatically available via `AbilityLoader`
|
||||
5. No code changes required!
|
||||
|
||||
## Guidelines
|
||||
|
||||
**Power Scaling:**
|
||||
- Basic attacks: 5-10 base power
|
||||
- Spells: 20-40 base power
|
||||
- Skills: 10-25 base power
|
||||
- Scaling factor typically 0.5 (50% of stat)
|
||||
|
||||
**Mana Costs:**
|
||||
- Basic attacks: 0 MP
|
||||
- Low-tier spells: 5-10 MP
|
||||
- Mid-tier spells: 15-20 MP
|
||||
- High-tier spells: 25-30 MP
|
||||
- Ultimate abilities: 40-50 MP
|
||||
|
||||
**Cooldowns:**
|
||||
- No cooldown (0): Most spells and basic attacks
|
||||
- Short (1-2 turns): Common skills
|
||||
- Medium (3-5 turns): Powerful skills
|
||||
- Long (5-10 turns): Ultimate abilities
|
||||
|
||||
**Effect Duration:**
|
||||
- Instant effects (stun): 1 turn
|
||||
- Short DoT/HoT: 2-3 turns
|
||||
- Long DoT/HoT: 4-5 turns
|
||||
- Buffs/debuffs: 2-4 turns
|
||||
|
||||
**Effect Power:**
|
||||
- Weak DoT: 3-5 damage per turn
|
||||
- Medium DoT: 8-12 damage per turn
|
||||
- Strong DoT: 15-20 damage per turn
|
||||
- Stat modifiers: 3-10 points per stack
|
||||
16
api/app/data/abilities/basic_attack.yaml
Normal file
16
api/app/data/abilities/basic_attack.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Basic Attack - Default melee attack
|
||||
# Available to all characters, no mana cost, no cooldown
|
||||
|
||||
ability_id: "basic_attack"
|
||||
name: "Basic Attack"
|
||||
description: "A standard melee attack with your equipped weapon"
|
||||
ability_type: "attack"
|
||||
base_power: 5
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 0
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/fireball.yaml
Normal file
25
api/app/data/abilities/fireball.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Fireball - Offensive spell for Arcanist class
|
||||
# Deals fire damage and applies burning DoT
|
||||
|
||||
ability_id: "fireball"
|
||||
name: "Fireball"
|
||||
description: "Hurl a ball of fire at your enemies, dealing damage and burning them"
|
||||
ability_type: "spell"
|
||||
base_power: 30
|
||||
damage_type: "fire"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 15
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "burn"
|
||||
name: "Burning"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 5
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "fireball"
|
||||
26
api/app/data/abilities/heal.yaml
Normal file
26
api/app/data/abilities/heal.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Heal - Luminary class ability
|
||||
# Restores health to target ally
|
||||
|
||||
ability_id: "heal"
|
||||
name: "Heal"
|
||||
description: "Channel divine energy to restore an ally's health"
|
||||
ability_type: "spell"
|
||||
base_power: 25
|
||||
damage_type: "holy"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 10
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
# Healing is represented as negative DOT (HOT)
|
||||
- effect_id: "regeneration"
|
||||
name: "Regeneration"
|
||||
effect_type: "hot"
|
||||
duration: 2
|
||||
power: 5
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "heal"
|
||||
25
api/app/data/abilities/shield_bash.yaml
Normal file
25
api/app/data/abilities/shield_bash.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Shield Bash - Vanguard class ability
|
||||
# Deals damage and stuns the target
|
||||
|
||||
ability_id: "shield_bash"
|
||||
name: "Shield Bash"
|
||||
description: "Bash your enemy with your shield, dealing damage and stunning them briefly"
|
||||
ability_type: "skill"
|
||||
base_power: 10
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 5
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "stun"
|
||||
name: "Stunned"
|
||||
effect_type: "stun"
|
||||
duration: 1
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "shield_bash"
|
||||
295
api/app/data/action_prompts.yaml
Normal file
295
api/app/data/action_prompts.yaml
Normal file
@@ -0,0 +1,295 @@
|
||||
# Action Prompts Configuration
|
||||
#
|
||||
# Defines the predefined actions available to players during story progression.
|
||||
# Actions are filtered by user tier and location type.
|
||||
#
|
||||
# Tier hierarchy: FREE < BASIC < PREMIUM < ELITE
|
||||
# Location types: town, tavern, wilderness, dungeon, safe_area, library, any
|
||||
|
||||
action_prompts:
|
||||
# =============================================================================
|
||||
# FREE TIER ACTIONS (4)
|
||||
# Available to all players
|
||||
# =============================================================================
|
||||
|
||||
- prompt_id: ask_locals
|
||||
category: ask_question
|
||||
display_text: Ask locals for information
|
||||
description: Talk to NPCs to learn about quests, rumors, and local lore
|
||||
tier_required: free
|
||||
context_filter: [town, tavern]
|
||||
icon: chat
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player approaches locals in {{ game_state.current_location }} and asks for information.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
|
||||
Generate realistic NPC dialogue where locals share:
|
||||
- Local rumors or gossip
|
||||
- Information about nearby points of interest
|
||||
- Hints about potential quests or dangers
|
||||
- Useful tips for adventurers
|
||||
|
||||
The NPCs should have distinct personalities and speak naturally. Include 1-2 NPCs in the response.
|
||||
End with a hook that encourages further exploration or action.
|
||||
|
||||
- prompt_id: explore_area
|
||||
category: explore
|
||||
display_text: Explore the area
|
||||
description: Search your surroundings for points of interest, hidden paths, or useful items
|
||||
tier_required: free
|
||||
context_filter: [wilderness, dungeon]
|
||||
icon: compass
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player explores the area around {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Perception modifier: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe what the player discovers:
|
||||
- Environmental details and atmosphere
|
||||
- Points of interest (paths, structures, natural features)
|
||||
- Any items, tracks, or clues found
|
||||
- Potential dangers or opportunities
|
||||
|
||||
Based on their Wisdom score, they may notice hidden details.
|
||||
|
||||
IMPORTANT: Do NOT automatically move the player to a new location.
|
||||
Present 2-3 options of what they can investigate or where they can go.
|
||||
Ask: "What would you like to investigate?" or "Which path do you take?"
|
||||
|
||||
- prompt_id: search_supplies
|
||||
category: gather_info
|
||||
display_text: Search for supplies
|
||||
description: Look for useful items, herbs, or materials in the environment
|
||||
tier_required: free
|
||||
context_filter: [any]
|
||||
icon: search
|
||||
cooldown_turns: 2
|
||||
requires_check:
|
||||
check_type: search
|
||||
difficulty: medium
|
||||
dm_prompt_template: |
|
||||
The player searches for supplies in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
|
||||
{% if check_outcome %}
|
||||
DICE CHECK RESULT: {{ check_outcome.check_result.success | string | upper }}
|
||||
- Roll: {{ check_outcome.check_result.roll }} + {{ check_outcome.check_result.modifier }} = {{ check_outcome.check_result.total }} vs DC {{ check_outcome.check_result.dc }}
|
||||
{% if check_outcome.check_result.success %}
|
||||
- Items found: {% for item in check_outcome.items_found %}{{ item.name }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
- Gold found: {{ check_outcome.gold_found }}
|
||||
|
||||
The player SUCCEEDED in their search. Narrate how they found these specific items.
|
||||
The items will be automatically added to their inventory - describe the discovery.
|
||||
{% else %}
|
||||
The player FAILED their search. Narrate the unsuccessful search attempt.
|
||||
They find nothing of value this time. Describe what they checked but came up empty.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Describe what supply sources or items they find based on location.
|
||||
{% endif %}
|
||||
|
||||
Keep the narration immersive and match the location type.
|
||||
|
||||
- prompt_id: rest_recover
|
||||
category: rest
|
||||
display_text: Rest and recover
|
||||
description: Take a short rest to recover health and stamina in a safe location
|
||||
tier_required: free
|
||||
context_filter: [town, tavern, safe_area]
|
||||
icon: bed
|
||||
cooldown_turns: 3
|
||||
dm_prompt_template: |
|
||||
The player wants to rest in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Current HP: {{ character.current_hp }}/{{ character.max_hp }}
|
||||
|
||||
For PAID rest (taverns/inns):
|
||||
- Describe the establishment and available rooms WITH PRICES
|
||||
- Ask which option they want before resting
|
||||
- DO NOT automatically spend their gold
|
||||
|
||||
For FREE rest (safe areas, campsites):
|
||||
- Describe finding a suitable spot
|
||||
- Describe the rest atmosphere and any ambient details
|
||||
- Dreams, thoughts, or reflections the character has
|
||||
|
||||
After they choose to rest:
|
||||
- The player recovers some health and feels refreshed
|
||||
- End with them ready to continue their adventure
|
||||
|
||||
# =============================================================================
|
||||
# PREMIUM TIER ACTIONS (+3)
|
||||
# Available to Premium and Elite subscribers
|
||||
# =============================================================================
|
||||
|
||||
- prompt_id: investigate_suspicious
|
||||
category: gather_info
|
||||
display_text: Investigate suspicious activity
|
||||
description: Look deeper into something that seems out of place or dangerous
|
||||
tier_required: premium
|
||||
context_filter: [any]
|
||||
icon: magnifying_glass
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player investigates suspicious activity in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Intelligence: {{ character.stats.intelligence | default(10) }}
|
||||
|
||||
Based on the location and recent events, describe:
|
||||
- What draws their attention
|
||||
- Clues or evidence they discover
|
||||
- Connections to larger mysteries or threats
|
||||
- Potential leads to follow
|
||||
|
||||
Higher Intelligence reveals more detailed observations.
|
||||
This should advance the story or reveal hidden plot elements.
|
||||
End with a clear lead or decision point.
|
||||
|
||||
- prompt_id: follow_lead
|
||||
category: travel
|
||||
display_text: Follow a lead
|
||||
description: Pursue information or tracks that could lead to your goal
|
||||
tier_required: premium
|
||||
context_filter: [any]
|
||||
icon: footprints
|
||||
cooldown_turns: 0
|
||||
dm_prompt_template: |
|
||||
The player follows a lead from {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
|
||||
Based on recent discoveries or conversations, describe:
|
||||
- What lead they're following (information, tracks, rumors)
|
||||
- The journey or investigation process
|
||||
- What they find at the end of the trail
|
||||
- New information or locations discovered
|
||||
|
||||
This should move the story forward significantly.
|
||||
May lead to new areas, NPCs, or quest opportunities.
|
||||
End with a meaningful discovery or encounter.
|
||||
|
||||
- prompt_id: make_camp
|
||||
category: rest
|
||||
display_text: Make camp
|
||||
description: Set up a campsite in the wilderness for rest and preparation
|
||||
tier_required: premium
|
||||
context_filter: [wilderness]
|
||||
icon: campfire
|
||||
cooldown_turns: 5
|
||||
dm_prompt_template: |
|
||||
The player sets up camp in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Survival skill: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe the camping experience:
|
||||
- Finding a suitable spot and setting up
|
||||
- Building a fire, preparing food
|
||||
- The night watch and any nocturnal events
|
||||
- Dreams or visions during sleep
|
||||
|
||||
Higher Wisdom means better campsite selection and awareness.
|
||||
May include random encounters (friendly travelers, animals, or threats).
|
||||
Player recovers health and is ready for the next day.
|
||||
|
||||
# =============================================================================
|
||||
# ELITE TIER ACTIONS (+3)
|
||||
# Available only to Elite subscribers
|
||||
# =============================================================================
|
||||
|
||||
- prompt_id: consult_texts
|
||||
category: special
|
||||
display_text: Consult ancient texts
|
||||
description: Study rare manuscripts and tomes for hidden knowledge and lore
|
||||
tier_required: elite
|
||||
context_filter: [library, town]
|
||||
icon: book
|
||||
cooldown_turns: 3
|
||||
dm_prompt_template: |
|
||||
The player consults ancient texts in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Intelligence: {{ character.stats.intelligence | default(10) }}
|
||||
|
||||
Describe the research session:
|
||||
- The library or collection they access
|
||||
- Specific tomes or scrolls they study
|
||||
- Ancient knowledge they uncover
|
||||
- Connections to current quests or mysteries
|
||||
|
||||
Higher Intelligence allows deeper understanding.
|
||||
May reveal:
|
||||
- Monster weaknesses or strategies
|
||||
- Hidden location details
|
||||
- Historical context for current events
|
||||
- Magical item properties or crafting recipes
|
||||
|
||||
End with actionable knowledge that helps their quest.
|
||||
|
||||
- prompt_id: commune_nature
|
||||
category: special
|
||||
display_text: Commune with nature
|
||||
description: Attune to the natural world to gain insights and guidance
|
||||
tier_required: elite
|
||||
context_filter: [wilderness]
|
||||
icon: leaf
|
||||
cooldown_turns: 4
|
||||
dm_prompt_template: |
|
||||
The player communes with nature in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Wisdom: {{ character.stats.wisdom | default(10) }}
|
||||
|
||||
Describe the mystical experience:
|
||||
- The ritual or meditation performed
|
||||
- Visions, sounds, or sensations received
|
||||
- Messages from the natural world
|
||||
- Animal messengers or nature spirits encountered
|
||||
|
||||
Higher Wisdom provides clearer visions.
|
||||
May reveal:
|
||||
- Danger ahead or safe paths
|
||||
- Weather changes or natural disasters
|
||||
- Animal behavior patterns
|
||||
- Locations of rare herbs or resources
|
||||
- Environmental quest hints
|
||||
|
||||
End with prophetic or practical guidance.
|
||||
|
||||
- prompt_id: seek_audience
|
||||
category: special
|
||||
display_text: Seek audience with authorities
|
||||
description: Request a meeting with local leaders, nobles, or officials
|
||||
tier_required: elite
|
||||
context_filter: [town]
|
||||
icon: crown
|
||||
cooldown_turns: 5
|
||||
dm_prompt_template: |
|
||||
The player seeks an audience with authorities in {{ game_state.current_location }}.
|
||||
|
||||
Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }}
|
||||
Charisma: {{ character.stats.charisma | default(10) }}
|
||||
Reputation: {{ character.reputation | default('unknown') }}
|
||||
|
||||
Describe the audience:
|
||||
- The authority figure (mayor, lord, guild master, etc.)
|
||||
- The setting and formality of the meeting
|
||||
- The conversation and requests made
|
||||
- The authority's response and any tasks given
|
||||
|
||||
Higher Charisma and reputation improve reception.
|
||||
May result in:
|
||||
- Official quests with better rewards
|
||||
- Access to restricted areas
|
||||
- Political information or alliances
|
||||
- Resources or equipment grants
|
||||
- Letters of introduction
|
||||
|
||||
End with a clear outcome and next steps.
|
||||
264
api/app/data/classes/arcanist.yaml
Normal file
264
api/app/data/classes/arcanist.yaml
Normal file
@@ -0,0 +1,264 @@
|
||||
# Arcanist - Magic Burst
|
||||
# Flexible hybrid class: Choose Pyromancy (fire AoE) or Cryomancy (ice control)
|
||||
|
||||
class_id: arcanist
|
||||
name: Arcanist
|
||||
description: >
|
||||
A master of elemental magic who bends the forces of fire and ice to their will. Arcanists
|
||||
excel in devastating spell damage, capable of incinerating groups of foes or freezing
|
||||
enemies in place. Choose your element: embrace the flames or command the frost.
|
||||
|
||||
# Base stats (total: 65)
|
||||
base_stats:
|
||||
strength: 8 # Low physical power
|
||||
dexterity: 10 # Average agility
|
||||
constitution: 9 # Below average endurance
|
||||
intelligence: 15 # Exceptional magical power
|
||||
wisdom: 12 # Above average perception
|
||||
charisma: 11 # Above average social
|
||||
|
||||
starting_equipment:
|
||||
- worn_staff
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== PYROMANCY (Fire AoE) ====================
|
||||
- tree_id: pyromancy
|
||||
name: Pyromancy
|
||||
description: >
|
||||
The path of flame. Master destructive fire magic to incinerate your enemies
|
||||
with overwhelming area damage and burning DoTs.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: fireball
|
||||
name: Fireball
|
||||
description: Hurl a ball of flame at an enemy, dealing fire damage and igniting them.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- fireball
|
||||
|
||||
- skill_id: flame_attunement
|
||||
name: Flame Attunement
|
||||
description: Your affinity with fire magic increases your magical power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: flame_burst
|
||||
name: Flame Burst
|
||||
description: Release a burst of fire around you, damaging all nearby enemies.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- fireball
|
||||
effects:
|
||||
abilities:
|
||||
- flame_burst
|
||||
|
||||
- skill_id: burning_soul
|
||||
name: Burning Soul
|
||||
description: Your inner fire burns brighter, increasing fire damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- flame_attunement
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
fire_damage_bonus: 0.15 # +15% fire damage
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: inferno
|
||||
name: Inferno
|
||||
description: Summon a raging inferno that burns all enemies for 3 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- flame_burst
|
||||
effects:
|
||||
abilities:
|
||||
- inferno
|
||||
|
||||
- skill_id: combustion
|
||||
name: Combustion
|
||||
description: Your fire spells can cause targets to explode on death, damaging nearby enemies.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- burning_soul
|
||||
effects:
|
||||
passive_effects:
|
||||
- burning_enemies_explode_on_death
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: firestorm
|
||||
name: Firestorm
|
||||
description: Call down a storm of meteors on all enemies, dealing massive fire damage.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- inferno
|
||||
effects:
|
||||
abilities:
|
||||
- firestorm
|
||||
|
||||
- skill_id: pyroclasm
|
||||
name: Pyroclasm
|
||||
description: Your mastery of flame makes all fire spells more devastating.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- combustion
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
fire_damage_bonus: 0.25 # Additional +25% fire damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: sun_burst
|
||||
name: Sun Burst
|
||||
description: Channel the power of the sun itself, dealing catastrophic fire damage to all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- firestorm
|
||||
effects:
|
||||
abilities:
|
||||
- sun_burst
|
||||
|
||||
- skill_id: master_of_flame
|
||||
name: Master of Flame
|
||||
description: You are flame incarnate. Incredible fire magic bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- pyroclasm
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 20
|
||||
combat_bonuses:
|
||||
fire_damage_bonus: 0.50 # Additional +50% fire damage
|
||||
|
||||
# ==================== CRYOMANCY (Ice Control) ====================
|
||||
- tree_id: cryomancy
|
||||
name: Cryomancy
|
||||
description: >
|
||||
The path of frost. Master ice magic to freeze and slow enemies,
|
||||
controlling the battlefield with chilling precision.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: ice_shard
|
||||
name: Ice Shard
|
||||
description: Launch a shard of ice at an enemy, dealing damage and slowing them.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- ice_shard
|
||||
|
||||
- skill_id: frost_attunement
|
||||
name: Frost Attunement
|
||||
description: Your affinity with ice magic increases your magical power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: frozen_orb
|
||||
name: Frozen Orb
|
||||
description: Summon an orb of ice that explodes, freezing enemies in place for 1 turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- ice_shard
|
||||
effects:
|
||||
abilities:
|
||||
- frozen_orb
|
||||
|
||||
- skill_id: cold_embrace
|
||||
name: Cold Embrace
|
||||
description: The cold empowers you, increasing ice damage and mana.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- frost_attunement
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
ice_damage_bonus: 0.15 # +15% ice damage
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: blizzard
|
||||
name: Blizzard
|
||||
description: Summon a raging blizzard that damages and slows all enemies.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- frozen_orb
|
||||
effects:
|
||||
abilities:
|
||||
- blizzard
|
||||
|
||||
- skill_id: permafrost
|
||||
name: Permafrost
|
||||
description: Your ice magic becomes more potent, with longer freeze durations.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- cold_embrace
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
freeze_duration_bonus: 1 # +1 turn to freeze effects
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: glacial_spike
|
||||
name: Glacial Spike
|
||||
description: Impale an enemy with a massive ice spike, dealing heavy damage and freezing them.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- blizzard
|
||||
effects:
|
||||
abilities:
|
||||
- glacial_spike
|
||||
|
||||
- skill_id: ice_mastery
|
||||
name: Ice Mastery
|
||||
description: Your command of ice magic reaches new heights.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- permafrost
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
ice_damage_bonus: 0.25 # Additional +25% ice damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: absolute_zero
|
||||
name: Absolute Zero
|
||||
description: Freeze all enemies solid for 2 turns while dealing massive damage over time.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- glacial_spike
|
||||
effects:
|
||||
abilities:
|
||||
- absolute_zero
|
||||
|
||||
- skill_id: winter_incarnate
|
||||
name: Winter Incarnate
|
||||
description: You become the embodiment of winter itself. Incredible ice magic bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- ice_mastery
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 20
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
ice_damage_bonus: 0.50 # Additional +50% ice damage
|
||||
265
api/app/data/classes/assassin.yaml
Normal file
265
api/app/data/classes/assassin.yaml
Normal file
@@ -0,0 +1,265 @@
|
||||
# Assassin - Critical/Stealth
|
||||
# Flexible hybrid class: Choose Shadow Dancer (stealth/evasion) or Blade Specialist (critical damage)
|
||||
|
||||
class_id: assassin
|
||||
name: Assassin
|
||||
description: >
|
||||
A deadly operative who strikes from the shadows. Assassins excel in precise, devastating attacks,
|
||||
capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace
|
||||
the shadows or perfect the killing blow.
|
||||
|
||||
# Base stats (total: 65)
|
||||
base_stats:
|
||||
strength: 11 # Above average physical power
|
||||
dexterity: 15 # Exceptional agility
|
||||
constitution: 10 # Average endurance
|
||||
intelligence: 9 # Below average magic
|
||||
wisdom: 10 # Average perception
|
||||
charisma: 10 # Average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_dagger
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== SHADOW DANCER (Stealth/Evasion) ====================
|
||||
- tree_id: shadow_dancer
|
||||
name: Shadow Dancer
|
||||
description: >
|
||||
The path of the phantom. Master stealth and evasion to become untouchable,
|
||||
striking from darkness and vanishing before retaliation.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: shadowstep
|
||||
name: Shadowstep
|
||||
description: Teleport behind an enemy and strike, dealing bonus damage from behind.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- shadowstep
|
||||
|
||||
- skill_id: nimble
|
||||
name: Nimble
|
||||
description: Your natural agility is enhanced through training.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: smoke_bomb
|
||||
name: Smoke Bomb
|
||||
description: Throw a smoke bomb, becoming untargetable for 1 turn and gaining evasion bonus.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- shadowstep
|
||||
effects:
|
||||
abilities:
|
||||
- smoke_bomb
|
||||
|
||||
- skill_id: evasion_training
|
||||
name: Evasion Training
|
||||
description: Learn to anticipate and dodge incoming attacks.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- nimble
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.15 # +15% chance to evade attacks
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: vanish
|
||||
name: Vanish
|
||||
description: Disappear from the battlefield for 2 turns, removing all threat and repositioning.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- smoke_bomb
|
||||
effects:
|
||||
abilities:
|
||||
- vanish
|
||||
|
||||
- skill_id: shadow_form
|
||||
name: Shadow Form
|
||||
description: Your body becomes harder to hit, permanently increasing evasion.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- evasion_training
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.10 # Additional +10% evasion
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: death_mark
|
||||
name: Death Mark
|
||||
description: Mark an enemy from stealth. Your next attack on them deals 200% damage.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- vanish
|
||||
effects:
|
||||
abilities:
|
||||
- death_mark
|
||||
|
||||
- skill_id: untouchable
|
||||
name: Untouchable
|
||||
description: Your mastery of evasion makes you extremely difficult to hit.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- shadow_form
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.15 # Additional +15% evasion
|
||||
stat_bonuses:
|
||||
dexterity: 10
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: shadow_assault
|
||||
name: Shadow Assault
|
||||
description: Strike all enemies in rapid succession from the shadows, guaranteed critical hits.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- death_mark
|
||||
effects:
|
||||
abilities:
|
||||
- shadow_assault
|
||||
|
||||
- skill_id: ghost
|
||||
name: Ghost
|
||||
description: Become one with the shadows. Massive evasion and dexterity bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- untouchable
|
||||
effects:
|
||||
combat_bonuses:
|
||||
evasion_chance: 0.20 # Additional +20% evasion (total can reach ~60%)
|
||||
stat_bonuses:
|
||||
dexterity: 15
|
||||
|
||||
# ==================== BLADE SPECIALIST (Critical Damage) ====================
|
||||
- tree_id: blade_specialist
|
||||
name: Blade Specialist
|
||||
description: >
|
||||
The path of precision. Master the art of the killing blow to deliver devastating
|
||||
critical strikes that end fights in seconds.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: precise_strike
|
||||
name: Precise Strike
|
||||
description: A carefully aimed attack with increased critical hit chance.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- precise_strike
|
||||
|
||||
- skill_id: keen_edge
|
||||
name: Keen Edge
|
||||
description: Sharpen your weapons to a razor edge, increasing critical chance.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.10 # +10% base crit
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: vital_strike
|
||||
name: Vital Strike
|
||||
description: Target vital points to deal massive critical damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- precise_strike
|
||||
effects:
|
||||
abilities:
|
||||
- vital_strike
|
||||
|
||||
- skill_id: deadly_precision
|
||||
name: Deadly Precision
|
||||
description: Your strikes become even more lethal.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- keen_edge
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.10 # Additional +10% crit
|
||||
crit_multiplier: 0.3 # +0.3 to crit multiplier
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: hemorrhage
|
||||
name: Hemorrhage
|
||||
description: Critical hits cause bleeding for 3 turns, dealing heavy damage over time.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- vital_strike
|
||||
effects:
|
||||
passive_effects:
|
||||
- crit_applies_bleed
|
||||
|
||||
- skill_id: surgical_strikes
|
||||
name: Surgical Strikes
|
||||
description: Every attack is a calculated execution.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- deadly_precision
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.15 # Additional +15% crit
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: coup_de_grace
|
||||
name: Coup de Grace
|
||||
description: Execute targets below 25% HP instantly with a guaranteed critical.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- hemorrhage
|
||||
effects:
|
||||
abilities:
|
||||
- coup_de_grace
|
||||
|
||||
- skill_id: master_assassin
|
||||
name: Master Assassin
|
||||
description: Your expertise with blades reaches perfection.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- surgical_strikes
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.10 # Additional +10% crit
|
||||
crit_multiplier: 0.5 # +0.5 to crit multiplier
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: thousand_cuts
|
||||
name: Thousand Cuts
|
||||
description: Unleash a flurry of blade strikes on a single target, each hit has 50% crit chance.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- coup_de_grace
|
||||
effects:
|
||||
abilities:
|
||||
- thousand_cuts
|
||||
|
||||
- skill_id: perfect_assassination
|
||||
name: Perfect Assassination
|
||||
description: Your mastery of the blade is unmatched. Incredible critical bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_assassin
|
||||
effects:
|
||||
combat_bonuses:
|
||||
crit_chance: 0.20 # Additional +20% crit (total can reach ~75%)
|
||||
crit_multiplier: 1.0 # +1.0 to crit multiplier
|
||||
stat_bonuses:
|
||||
dexterity: 10
|
||||
strength: 10
|
||||
273
api/app/data/classes/lorekeeper.yaml
Normal file
273
api/app/data/classes/lorekeeper.yaml
Normal file
@@ -0,0 +1,273 @@
|
||||
# Lorekeeper - Support/Control
|
||||
# Flexible hybrid class: Choose Arcane Weaving (buffs/debuffs) or Illusionist (crowd control)
|
||||
|
||||
class_id: lorekeeper
|
||||
name: Lorekeeper
|
||||
description: >
|
||||
A master of arcane knowledge who manipulates reality through words and illusions. Lorekeepers
|
||||
excel in supporting allies and controlling enemies through clever magic and mental manipulation.
|
||||
Choose your art: weave arcane power or bend reality itself.
|
||||
|
||||
# Base stats (total: 67)
|
||||
base_stats:
|
||||
strength: 8 # Low physical power
|
||||
dexterity: 11 # Above average agility
|
||||
constitution: 10 # Average endurance
|
||||
intelligence: 13 # Above average magical power
|
||||
wisdom: 11 # Above average perception
|
||||
charisma: 14 # High social/performance
|
||||
|
||||
starting_equipment:
|
||||
- tome
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== ARCANE WEAVING (Buffs/Debuffs) ====================
|
||||
- tree_id: arcane_weaving
|
||||
name: Arcane Weaving
|
||||
description: >
|
||||
The path of the arcane weaver. Master supportive magic to enhance allies,
|
||||
weaken enemies, and turn the tide of battle through clever enchantments.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: arcane_brilliance
|
||||
name: Arcane Brilliance
|
||||
description: Grant an ally increased intelligence and magical power for 5 turns.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- arcane_brilliance
|
||||
|
||||
- skill_id: scholarly_mind
|
||||
name: Scholarly Mind
|
||||
description: Your extensive study enhances your magical knowledge.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: haste
|
||||
name: Haste
|
||||
description: Speed up an ally, granting them an extra action this turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- arcane_brilliance
|
||||
effects:
|
||||
abilities:
|
||||
- haste
|
||||
|
||||
- skill_id: arcane_mastery
|
||||
name: Arcane Mastery
|
||||
description: Your mastery of arcane arts increases all buff effectiveness.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- scholarly_mind
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
charisma: 3
|
||||
combat_bonuses:
|
||||
buff_power: 0.20 # +20% buff effectiveness
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: mass_enhancement
|
||||
name: Mass Enhancement
|
||||
description: Enhance all allies at once, increasing their stats for 5 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- haste
|
||||
effects:
|
||||
abilities:
|
||||
- mass_enhancement
|
||||
|
||||
- skill_id: arcane_weakness
|
||||
name: Arcane Weakness
|
||||
description: Curse an enemy with weakness, reducing their stats and damage.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- arcane_mastery
|
||||
effects:
|
||||
abilities:
|
||||
- arcane_weakness
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: time_warp
|
||||
name: Time Warp
|
||||
description: Manipulate time itself, granting all allies bonus actions.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mass_enhancement
|
||||
effects:
|
||||
abilities:
|
||||
- time_warp
|
||||
|
||||
- skill_id: master_weaver
|
||||
name: Master Weaver
|
||||
description: Your weaving expertise makes all enchantments far more potent.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- arcane_weakness
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 15
|
||||
charisma: 10
|
||||
combat_bonuses:
|
||||
buff_power: 0.35 # Additional +35% buff effectiveness
|
||||
debuff_power: 0.35 # +35% debuff effectiveness
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: reality_shift
|
||||
name: Reality Shift
|
||||
description: Shift reality to massively empower all allies and weaken all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- time_warp
|
||||
effects:
|
||||
abilities:
|
||||
- reality_shift
|
||||
|
||||
- skill_id: archmage
|
||||
name: Archmage
|
||||
description: Achieve the rank of archmage. Incredible support magic bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_weaver
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 25
|
||||
charisma: 20
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
buff_power: 0.75 # Additional +75% buff effectiveness
|
||||
debuff_power: 0.75 # Additional +75% debuff effectiveness
|
||||
|
||||
# ==================== ILLUSIONIST (Crowd Control) ====================
|
||||
- tree_id: illusionist
|
||||
name: Illusionist
|
||||
description: >
|
||||
The path of deception. Master illusion magic to confuse, disorient, and control
|
||||
the minds of your enemies, rendering them helpless.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: confuse
|
||||
name: Confuse
|
||||
description: Confuse an enemy's mind, causing them to attack randomly for 2 turns.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- confuse
|
||||
|
||||
- skill_id: silver_tongue
|
||||
name: Silver Tongue
|
||||
description: Your persuasive abilities make mind magic more effective.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: mesmerize
|
||||
name: Mesmerize
|
||||
description: Mesmerize an enemy, stunning them for 2 turns.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- confuse
|
||||
effects:
|
||||
abilities:
|
||||
- mesmerize
|
||||
|
||||
- skill_id: mental_fortress
|
||||
name: Mental Fortress
|
||||
description: Fortify your mind and those of your allies against mental attacks.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- silver_tongue
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
mental_resistance: 0.25 # +25% resistance to mind effects
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: mass_confusion
|
||||
name: Mass Confusion
|
||||
description: Confuse all enemies, causing chaos on the battlefield.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- mesmerize
|
||||
effects:
|
||||
abilities:
|
||||
- mass_confusion
|
||||
|
||||
- skill_id: mirror_image
|
||||
name: Mirror Image
|
||||
description: Create illusory copies of yourself that absorb attacks.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- mental_fortress
|
||||
effects:
|
||||
abilities:
|
||||
- mirror_image
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: phantasmal_killer
|
||||
name: Phantasmal Killer
|
||||
description: Create a terrifying illusion that deals massive psychic damage and fears enemies.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mass_confusion
|
||||
effects:
|
||||
abilities:
|
||||
- phantasmal_killer
|
||||
|
||||
- skill_id: master_illusionist
|
||||
name: Master Illusionist
|
||||
description: Your illusions become nearly indistinguishable from reality.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mirror_image
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 15
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
illusion_duration: 2 # +2 turns to illusion effects
|
||||
cc_effectiveness: 0.35 # +35% crowd control effectiveness
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: mass_domination
|
||||
name: Mass Domination
|
||||
description: Dominate the minds of all enemies, forcing them to fight for you briefly.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- phantasmal_killer
|
||||
effects:
|
||||
abilities:
|
||||
- mass_domination
|
||||
|
||||
- skill_id: grand_illusionist
|
||||
name: Grand Illusionist
|
||||
description: Become a grand illusionist. Reality bends to your will.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_illusionist
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 30
|
||||
intelligence: 15
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
illusion_duration: 5 # Additional +5 turns to illusions
|
||||
cc_effectiveness: 0.75 # Additional +75% crowd control effectiveness
|
||||
mental_damage_bonus: 1.0 # +100% psychic damage
|
||||
266
api/app/data/classes/luminary.yaml
Normal file
266
api/app/data/classes/luminary.yaml
Normal file
@@ -0,0 +1,266 @@
|
||||
# Luminary - Holy Healer/DPS
|
||||
# Flexible hybrid class: Choose Divine Protection (healing/shields) or Radiant Judgment (holy damage)
|
||||
|
||||
class_id: luminary
|
||||
name: Luminary
|
||||
description: >
|
||||
A blessed warrior who channels divine power. Luminaries excel in healing and protection,
|
||||
capable of becoming a guardian angel for their allies or a righteous crusader smiting evil.
|
||||
Choose your calling: protect the innocent or judge the wicked.
|
||||
|
||||
# Base stats (total: 68)
|
||||
base_stats:
|
||||
strength: 9 # Below average physical power
|
||||
dexterity: 9 # Below average agility
|
||||
constitution: 11 # Above average endurance
|
||||
intelligence: 12 # Above average magical power
|
||||
wisdom: 14 # High perception/divine power
|
||||
charisma: 13 # Above average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_mace
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== DIVINE PROTECTION (Healing/Shields) ====================
|
||||
- tree_id: divine_protection
|
||||
name: Divine Protection
|
||||
description: >
|
||||
The path of the guardian. Channel divine energy to heal wounds, shield allies,
|
||||
and protect the vulnerable from harm.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: heal
|
||||
name: Heal
|
||||
description: Channel divine energy to restore an ally's health.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- heal
|
||||
|
||||
- skill_id: divine_grace
|
||||
name: Divine Grace
|
||||
description: Your connection to the divine enhances your wisdom and healing power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: holy_shield
|
||||
name: Holy Shield
|
||||
description: Grant an ally a protective barrier that absorbs damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- heal
|
||||
effects:
|
||||
abilities:
|
||||
- holy_shield
|
||||
|
||||
- skill_id: blessed_aura
|
||||
name: Blessed Aura
|
||||
description: Emit an aura that passively regenerates nearby allies' health each turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- divine_grace
|
||||
effects:
|
||||
passive_effects:
|
||||
- aura_healing # 5% max HP per turn to allies
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: mass_heal
|
||||
name: Mass Heal
|
||||
description: Channel divine power to heal all allies at once.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- holy_shield
|
||||
effects:
|
||||
abilities:
|
||||
- mass_heal
|
||||
|
||||
- skill_id: guardian_angel
|
||||
name: Guardian Angel
|
||||
description: Place a protective blessing on an ally that prevents their next death.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- blessed_aura
|
||||
effects:
|
||||
abilities:
|
||||
- guardian_angel
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: divine_intervention
|
||||
name: Divine Intervention
|
||||
description: Call upon divine power to fully heal an ally and remove all debuffs.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- mass_heal
|
||||
effects:
|
||||
abilities:
|
||||
- divine_intervention
|
||||
|
||||
- skill_id: sanctified
|
||||
name: Sanctified
|
||||
description: Your divine power reaches new heights, improving all healing.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- guardian_angel
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
healing_power: 0.25 # +25% healing
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: resurrection
|
||||
name: Resurrection
|
||||
description: Bring a fallen ally back to life with 50% health and mana.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- divine_intervention
|
||||
effects:
|
||||
abilities:
|
||||
- resurrection
|
||||
|
||||
- skill_id: beacon_of_hope
|
||||
name: Beacon of Hope
|
||||
description: You radiate divine energy. Massive wisdom and healing bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- sanctified
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 20
|
||||
charisma: 10
|
||||
combat_bonuses:
|
||||
healing_power: 0.50 # Additional +50% healing
|
||||
|
||||
# ==================== RADIANT JUDGMENT (Holy Damage) ====================
|
||||
- tree_id: radiant_judgment
|
||||
name: Radiant Judgment
|
||||
description: >
|
||||
The path of the crusader. Wield holy power as a weapon, smiting the wicked
|
||||
with radiant damage and divine wrath.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: smite
|
||||
name: Smite
|
||||
description: Strike an enemy with holy power, dealing radiant damage.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- smite
|
||||
|
||||
- skill_id: righteous_fury
|
||||
name: Righteous Fury
|
||||
description: Your righteous anger fuels your holy power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: holy_fire
|
||||
name: Holy Fire
|
||||
description: Burn an enemy with holy flames, dealing damage and reducing their healing received.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- smite
|
||||
effects:
|
||||
abilities:
|
||||
- holy_fire
|
||||
|
||||
- skill_id: zealot
|
||||
name: Zealot
|
||||
description: Your devotion to righteousness increases your damage against evil.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- righteous_fury
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
strength: 3
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.15 # +15% holy damage
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: consecration
|
||||
name: Consecration
|
||||
description: Consecrate the ground, dealing holy damage to enemies standing in it each turn.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- holy_fire
|
||||
effects:
|
||||
abilities:
|
||||
- consecration
|
||||
|
||||
- skill_id: divine_wrath
|
||||
name: Divine Wrath
|
||||
description: Channel pure divine fury into your attacks.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- zealot
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.20 # Additional +20% holy damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: hammer_of_justice
|
||||
name: Hammer of Justice
|
||||
description: Summon a massive holy hammer to crush your foes, stunning and damaging them.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- consecration
|
||||
effects:
|
||||
abilities:
|
||||
- hammer_of_justice
|
||||
|
||||
- skill_id: crusader
|
||||
name: Crusader
|
||||
description: You become a true crusader, dealing devastating holy damage.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- divine_wrath
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
strength: 5
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.25 # Additional +25% holy damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: divine_storm
|
||||
name: Divine Storm
|
||||
description: Unleash a catastrophic storm of holy energy, damaging and stunning all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- hammer_of_justice
|
||||
effects:
|
||||
abilities:
|
||||
- divine_storm
|
||||
|
||||
- skill_id: avatar_of_light
|
||||
name: Avatar of Light
|
||||
description: Become an avatar of divine light itself. Incredible holy damage bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- crusader
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 20
|
||||
strength: 10
|
||||
charisma: 10
|
||||
combat_bonuses:
|
||||
holy_damage_bonus: 0.50 # Additional +50% holy damage
|
||||
275
api/app/data/classes/necromancer.yaml
Normal file
275
api/app/data/classes/necromancer.yaml
Normal file
@@ -0,0 +1,275 @@
|
||||
# Necromancer - DoT/Summoner
|
||||
# Flexible hybrid class: Choose Dark Affliction (DoTs/debuffs) or Raise Dead (summon undead)
|
||||
|
||||
class_id: necromancer
|
||||
name: Necromancer
|
||||
description: >
|
||||
A master of death magic who manipulates life force and commands the undead. Necromancers
|
||||
excel in draining enemies over time or overwhelming foes with undead minions.
|
||||
Choose your dark art: curse your enemies or raise an army of the dead.
|
||||
|
||||
# Base stats (total: 65)
|
||||
base_stats:
|
||||
strength: 8 # Low physical power
|
||||
dexterity: 10 # Average agility
|
||||
constitution: 10 # Average endurance
|
||||
intelligence: 14 # High magical power
|
||||
wisdom: 11 # Above average perception
|
||||
charisma: 12 # Above average social (commands undead)
|
||||
|
||||
starting_equipment:
|
||||
- bone_wand
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== DARK AFFLICTION (DoTs/Debuffs) ====================
|
||||
- tree_id: dark_affliction
|
||||
name: Dark Affliction
|
||||
description: >
|
||||
The path of the curseweaver. Master dark magic to drain life, inflict agonizing
|
||||
curses, and watch enemies wither away over time.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: drain_life
|
||||
name: Drain Life
|
||||
description: Siphon life force from an enemy, damaging them and healing yourself.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- drain_life
|
||||
|
||||
- skill_id: dark_knowledge
|
||||
name: Dark Knowledge
|
||||
description: Study of forbidden arts enhances your dark magic power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: plague
|
||||
name: Plague
|
||||
description: Infect an enemy with disease that spreads to nearby foes, dealing damage over time.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- drain_life
|
||||
effects:
|
||||
abilities:
|
||||
- plague
|
||||
|
||||
- skill_id: soul_harvest
|
||||
name: Soul Harvest
|
||||
description: Absorb the life essence of dying enemies, increasing your power.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- dark_knowledge
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
lifesteal: 0.15 # Heal for 15% of damage dealt
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: curse_of_agony
|
||||
name: Curse of Agony
|
||||
description: Curse an enemy with excruciating pain, dealing heavy damage over 5 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- plague
|
||||
effects:
|
||||
abilities:
|
||||
- curse_of_agony
|
||||
|
||||
- skill_id: dark_empowerment
|
||||
name: Dark Empowerment
|
||||
description: Channel dark energy to enhance all damage over time effects.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- soul_harvest
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 10
|
||||
combat_bonuses:
|
||||
dot_damage_bonus: 0.30 # +30% DoT damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: soul_rot
|
||||
name: Soul Rot
|
||||
description: Rot an enemy's soul, dealing massive damage over time and reducing their healing.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- curse_of_agony
|
||||
effects:
|
||||
abilities:
|
||||
- soul_rot
|
||||
|
||||
- skill_id: death_mastery
|
||||
name: Death Mastery
|
||||
description: Master the art of death magic, dramatically increasing curse potency.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- dark_empowerment
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 15
|
||||
combat_bonuses:
|
||||
dot_damage_bonus: 0.40 # Additional +40% DoT damage
|
||||
lifesteal: 0.15 # Additional +15% lifesteal
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: epidemic
|
||||
name: Epidemic
|
||||
description: Unleash a deadly epidemic that afflicts all enemies with multiple DoTs.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- soul_rot
|
||||
effects:
|
||||
abilities:
|
||||
- epidemic
|
||||
|
||||
- skill_id: lord_of_decay
|
||||
name: Lord of Decay
|
||||
description: Become a lord of death and decay. Incredible DoT and drain bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- death_mastery
|
||||
effects:
|
||||
stat_bonuses:
|
||||
intelligence: 25
|
||||
wisdom: 15
|
||||
combat_bonuses:
|
||||
dot_damage_bonus: 1.0 # Additional +100% DoT damage
|
||||
lifesteal: 0.30 # Additional +30% lifesteal
|
||||
|
||||
# ==================== RAISE DEAD (Summon Undead) ====================
|
||||
- tree_id: raise_dead
|
||||
name: Raise Dead
|
||||
description: >
|
||||
The path of the necromancer. Command armies of the undead, raising fallen
|
||||
enemies and empowering your minions with dark magic.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: summon_skeleton
|
||||
name: Summon Skeleton
|
||||
description: Raise a skeleton warrior from the ground to fight for you.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- summon_skeleton
|
||||
|
||||
- skill_id: dark_command
|
||||
name: Dark Command
|
||||
description: Your mastery over the undead makes your minions stronger.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 5
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 0.15 # +15% minion damage
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: raise_ghoul
|
||||
name: Raise Ghoul
|
||||
description: Summon a ravenous ghoul that deals heavy melee damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- summon_skeleton
|
||||
effects:
|
||||
abilities:
|
||||
- raise_ghoul
|
||||
|
||||
- skill_id: unholy_bond
|
||||
name: Unholy Bond
|
||||
description: Strengthen your connection to the undead, empowering your minions.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- dark_command
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 5
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 0.20 # Additional +20% minion damage
|
||||
minion_health_bonus: 0.25 # +25% minion HP
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: corpse_explosion
|
||||
name: Corpse Explosion
|
||||
description: Detonate a corpse or minion, dealing massive AoE damage.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- raise_ghoul
|
||||
effects:
|
||||
abilities:
|
||||
- corpse_explosion
|
||||
|
||||
- skill_id: death_pact
|
||||
name: Death Pact
|
||||
description: Sacrifice a minion to restore your health and mana.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- unholy_bond
|
||||
effects:
|
||||
abilities:
|
||||
- death_pact
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: summon_abomination
|
||||
name: Summon Abomination
|
||||
description: Raise a massive undead abomination that dominates the battlefield.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- corpse_explosion
|
||||
effects:
|
||||
abilities:
|
||||
- summon_abomination
|
||||
|
||||
- skill_id: legion_master
|
||||
name: Legion Master
|
||||
description: Command larger armies of undead with increased effectiveness.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- death_pact
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 15
|
||||
intelligence: 5
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 0.35 # Additional +35% minion damage
|
||||
minion_health_bonus: 0.50 # Additional +50% minion HP
|
||||
max_minions: 2 # +2 max minions
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: army_of_the_dead
|
||||
name: Army of the Dead
|
||||
description: Summon a massive army of undead warriors to overwhelm your enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- summon_abomination
|
||||
effects:
|
||||
abilities:
|
||||
- army_of_the_dead
|
||||
|
||||
- skill_id: lich_lord
|
||||
name: Lich Lord
|
||||
description: Transcend mortality to become a lich lord. Incredible minion bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- legion_master
|
||||
effects:
|
||||
stat_bonuses:
|
||||
charisma: 25
|
||||
intelligence: 20
|
||||
combat_bonuses:
|
||||
minion_damage_bonus: 1.0 # Additional +100% minion damage
|
||||
minion_health_bonus: 1.0 # Additional +100% minion HP
|
||||
max_minions: 3 # Additional +3 max minions
|
||||
265
api/app/data/classes/oathkeeper.yaml
Normal file
265
api/app/data/classes/oathkeeper.yaml
Normal file
@@ -0,0 +1,265 @@
|
||||
# Oathkeeper - Hybrid Tank/Healer
|
||||
# Flexible hybrid class: Choose Aegis of Light (protection/tanking) or Redemption (healing/support)
|
||||
|
||||
class_id: oathkeeper
|
||||
name: Oathkeeper
|
||||
description: >
|
||||
A sacred warrior bound by holy oaths. Oathkeepers excel as versatile protectors,
|
||||
capable of becoming an unyielding shield for their allies or a beacon of healing light.
|
||||
Choose your oath: defend the weak or redeem the fallen.
|
||||
|
||||
# Base stats (total: 67)
|
||||
base_stats:
|
||||
strength: 12 # Above average physical power
|
||||
dexterity: 9 # Below average agility
|
||||
constitution: 13 # High endurance
|
||||
intelligence: 10 # Average magic
|
||||
wisdom: 12 # Above average perception
|
||||
charisma: 11 # Above average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_sword
|
||||
- rusty_shield
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== AEGIS OF LIGHT (Protection/Tanking) ====================
|
||||
- tree_id: aegis_of_light
|
||||
name: Aegis of Light
|
||||
description: >
|
||||
The path of the protector. Become an unyielding guardian who shields allies
|
||||
from harm and draws enemy attention through divine resilience.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: taunt
|
||||
name: Taunt
|
||||
description: Challenge enemies to attack you instead of your allies.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- taunt
|
||||
|
||||
- skill_id: blessed_armor
|
||||
name: Blessed Armor
|
||||
description: Divine power enhances your natural toughness.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: shield_of_faith
|
||||
name: Shield of Faith
|
||||
description: Conjure a holy shield that absorbs damage for you and nearby allies.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- taunt
|
||||
effects:
|
||||
abilities:
|
||||
- shield_of_faith
|
||||
|
||||
- skill_id: sacred_resilience
|
||||
name: Sacred Resilience
|
||||
description: Your oath grants you resistance to harm.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- blessed_armor
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 5
|
||||
resistance: 5
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: consecrated_ground
|
||||
name: Consecrated Ground
|
||||
description: Bless the ground beneath you, providing damage reduction to allies standing in it.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- shield_of_faith
|
||||
effects:
|
||||
abilities:
|
||||
- consecrated_ground
|
||||
|
||||
- skill_id: unbreakable_oath
|
||||
name: Unbreakable Oath
|
||||
description: Your oath makes you incredibly difficult to bring down.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- sacred_resilience
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 10
|
||||
defense: 10
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: divine_aegis
|
||||
name: Divine Aegis
|
||||
description: Summon a massive divine shield that protects all allies for 3 turns.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- consecrated_ground
|
||||
effects:
|
||||
abilities:
|
||||
- divine_aegis
|
||||
|
||||
- skill_id: indomitable
|
||||
name: Indomitable
|
||||
description: Nothing can break your will or body. Massive defensive bonuses.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- unbreakable_oath
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 15
|
||||
defense: 15
|
||||
combat_bonuses:
|
||||
damage_reduction: 0.15 # Reduce all damage by 15%
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: last_stand
|
||||
name: Last Stand
|
||||
description: Become invulnerable for 3 turns while taunting all enemies. Cannot be canceled.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- divine_aegis
|
||||
effects:
|
||||
abilities:
|
||||
- last_stand
|
||||
|
||||
- skill_id: eternal_guardian
|
||||
name: Eternal Guardian
|
||||
description: You are an eternal bastion of protection. Incredible defensive bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- indomitable
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 25
|
||||
defense: 20
|
||||
resistance: 15
|
||||
combat_bonuses:
|
||||
damage_reduction: 0.30 # Additional 30% damage reduction
|
||||
|
||||
# ==================== REDEMPTION (Healing/Support) ====================
|
||||
- tree_id: redemption
|
||||
name: Redemption
|
||||
description: >
|
||||
The path of the redeemer. Channel divine power to heal wounds, cleanse corruption,
|
||||
and grant your allies second chances through sacred intervention.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: lay_on_hands
|
||||
name: Lay on Hands
|
||||
description: Touch an ally to restore their health through divine power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- lay_on_hands
|
||||
|
||||
- skill_id: divine_wisdom
|
||||
name: Divine Wisdom
|
||||
description: Your wisdom grows through devotion to your oath.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: cleanse
|
||||
name: Cleanse
|
||||
description: Remove all debuffs and negative effects from an ally.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- lay_on_hands
|
||||
effects:
|
||||
abilities:
|
||||
- cleanse
|
||||
|
||||
- skill_id: aura_of_mercy
|
||||
name: Aura of Mercy
|
||||
description: Emit a merciful aura that slowly heals all nearby allies each turn.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- divine_wisdom
|
||||
effects:
|
||||
passive_effects:
|
||||
- healing_aura # 3% max HP per turn
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: word_of_healing
|
||||
name: Word of Healing
|
||||
description: Speak a divine word that heals all allies within range.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- cleanse
|
||||
effects:
|
||||
abilities:
|
||||
- word_of_healing
|
||||
|
||||
- skill_id: blessed_sacrifice
|
||||
name: Blessed Sacrifice
|
||||
description: Transfer an ally's wounds to yourself, healing them while you take damage.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- aura_of_mercy
|
||||
effects:
|
||||
abilities:
|
||||
- blessed_sacrifice
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: divine_blessing
|
||||
name: Divine Blessing
|
||||
description: Grant an ally a powerful blessing that increases their stats and regenerates health.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- word_of_healing
|
||||
effects:
|
||||
abilities:
|
||||
- divine_blessing
|
||||
|
||||
- skill_id: martyr
|
||||
name: Martyr
|
||||
description: Your willingness to sacrifice yourself empowers your healing abilities.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- blessed_sacrifice
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 15
|
||||
combat_bonuses:
|
||||
healing_power: 0.35 # +35% healing
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: miracle
|
||||
name: Miracle
|
||||
description: Perform a divine miracle, fully healing all allies and removing all debuffs.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- divine_blessing
|
||||
effects:
|
||||
abilities:
|
||||
- miracle
|
||||
|
||||
- skill_id: sainthood
|
||||
name: Sainthood
|
||||
description: Achieve sainthood through your devotion. Incredible healing and support bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- martyr
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 25
|
||||
charisma: 15
|
||||
combat_bonuses:
|
||||
healing_power: 0.75 # Additional +75% healing
|
||||
aura_radius: 2 # Increase aura range
|
||||
264
api/app/data/classes/vanguard.yaml
Normal file
264
api/app/data/classes/vanguard.yaml
Normal file
@@ -0,0 +1,264 @@
|
||||
# Vanguard - Melee Tank/DPS
|
||||
# Flexible hybrid class: Choose Shield Bearer (tank) or Weapon Master (DPS)
|
||||
|
||||
class_id: vanguard
|
||||
name: Vanguard
|
||||
description: >
|
||||
A seasoned warrior who stands at the front lines of battle. Vanguards excel in melee combat,
|
||||
capable of becoming an unbreakable shield for their allies or a relentless damage dealer.
|
||||
Choose your path: become a stalwart defender or a devastating weapon master.
|
||||
|
||||
# Base stats (total: 65, average: 10.83)
|
||||
base_stats:
|
||||
strength: 14 # High physical power
|
||||
dexterity: 10 # Average agility
|
||||
constitution: 14 # High endurance for tanking
|
||||
intelligence: 8 # Low magic
|
||||
wisdom: 10 # Average perception
|
||||
charisma: 9 # Below average social
|
||||
|
||||
# Starting equipment (minimal)
|
||||
starting_equipment:
|
||||
- rusty_sword
|
||||
- cloth_armor
|
||||
- rusty_knife # Everyone gets pocket knife
|
||||
|
||||
# Starting abilities
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
# Skill trees (mutually exclusive playstyles)
|
||||
skill_trees:
|
||||
# ==================== SHIELD BEARER (Tank) ====================
|
||||
- tree_id: shield_bearer
|
||||
name: Shield Bearer
|
||||
description: >
|
||||
The path of the defender. Master the shield to become an impenetrable fortress,
|
||||
protecting your allies and controlling the battlefield.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: shield_bash
|
||||
name: Shield Bash
|
||||
description: Strike an enemy with your shield, dealing minor damage and stunning them for 1 turn.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- shield_bash # References ability YAML
|
||||
|
||||
- skill_id: fortify
|
||||
name: Fortify
|
||||
description: Your defensive training grants you enhanced protection.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
defense: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: shield_wall
|
||||
name: Shield Wall
|
||||
description: Raise your shield to block incoming attacks, reducing damage by 50% for 3 turns.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- shield_bash
|
||||
effects:
|
||||
abilities:
|
||||
- shield_wall
|
||||
|
||||
- skill_id: iron_skin
|
||||
name: Iron Skin
|
||||
description: Your body becomes hardened through relentless training.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- fortify
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 5
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: guardians_resolve
|
||||
name: Guardian's Resolve
|
||||
description: Your unwavering determination makes you nearly impossible to break.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- shield_wall
|
||||
effects:
|
||||
stat_bonuses:
|
||||
defense: 10
|
||||
passive_effects:
|
||||
- stun_resistance # Immune to stun when shield wall active
|
||||
|
||||
- skill_id: riposte
|
||||
name: Riposte
|
||||
description: After blocking an attack, counter with a swift strike.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- iron_skin
|
||||
effects:
|
||||
abilities:
|
||||
- riposte
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: bulwark
|
||||
name: Bulwark
|
||||
description: You are a living fortress, shrugging off blows that would fell lesser warriors.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- guardians_resolve
|
||||
effects:
|
||||
stat_bonuses:
|
||||
constitution: 10
|
||||
resistance: 5
|
||||
|
||||
- skill_id: counter_strike
|
||||
name: Counter Strike
|
||||
description: Enhance your Riposte ability to deal critical damage when countering.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- riposte
|
||||
effects:
|
||||
ability_enhancements:
|
||||
riposte:
|
||||
crit_chance_bonus: 0.3 # +30% crit on riposte
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: unbreakable
|
||||
name: Unbreakable
|
||||
description: Channel your inner strength to become invulnerable, reducing all damage by 75% for 5 turns.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- bulwark
|
||||
effects:
|
||||
abilities:
|
||||
- unbreakable # Ultimate defensive ability
|
||||
|
||||
- skill_id: fortress
|
||||
name: Fortress
|
||||
description: Your defensive mastery reaches its peak. Permanently gain massive defensive bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- counter_strike
|
||||
effects:
|
||||
stat_bonuses:
|
||||
defense: 20
|
||||
constitution: 10
|
||||
resistance: 10
|
||||
|
||||
# ==================== WEAPON MASTER (DPS) ====================
|
||||
- tree_id: weapon_master
|
||||
name: Weapon Master
|
||||
description: >
|
||||
The path of destruction. Master devastating melee techniques to cut through enemies
|
||||
with overwhelming physical power.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: power_strike
|
||||
name: Power Strike
|
||||
description: A heavy attack that deals 150% weapon damage.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- power_strike
|
||||
|
||||
- skill_id: weapon_proficiency
|
||||
name: Weapon Proficiency
|
||||
description: Your training with weapons grants increased physical power.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: cleave
|
||||
name: Cleave
|
||||
description: Swing your weapon in a wide arc, hitting all enemies in front of you.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- power_strike
|
||||
effects:
|
||||
abilities:
|
||||
- cleave # AoE attack
|
||||
|
||||
- skill_id: battle_frenzy
|
||||
name: Battle Frenzy
|
||||
description: The heat of battle fuels your strength.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- weapon_proficiency
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: rending_blow
|
||||
name: Rending Blow
|
||||
description: Strike with such force that your enemy bleeds for 3 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- cleave
|
||||
effects:
|
||||
abilities:
|
||||
- rending_blow # Applies bleed DoT
|
||||
|
||||
- skill_id: brutal_force
|
||||
name: Brutal Force
|
||||
description: Your attacks become devastatingly powerful.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- battle_frenzy
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 10
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: execute
|
||||
name: Execute
|
||||
description: Finish off weakened enemies. Deals bonus damage to targets below 30% HP.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- rending_blow
|
||||
effects:
|
||||
abilities:
|
||||
- execute
|
||||
|
||||
- skill_id: weapon_mastery
|
||||
name: Weapon Mastery
|
||||
description: Your expertise with weapons allows you to find weak points more easily.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- brutal_force
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 5
|
||||
combat_bonuses:
|
||||
crit_chance: 0.15 # +15% crit chance
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: titans_wrath
|
||||
name: Titan's Wrath
|
||||
description: Unleash a devastating attack that deals 300% weapon damage and stuns all enemies hit.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- execute
|
||||
effects:
|
||||
abilities:
|
||||
- titans_wrath # Ultimate offensive ability
|
||||
|
||||
- skill_id: perfect_form
|
||||
name: Perfect Form
|
||||
description: Your combat technique reaches perfection. Massive offensive bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- weapon_mastery
|
||||
effects:
|
||||
stat_bonuses:
|
||||
strength: 20
|
||||
dexterity: 10
|
||||
combat_bonuses:
|
||||
crit_chance: 0.1 # Additional +10% crit
|
||||
crit_multiplier: 0.5 # +0.5 to crit multiplier
|
||||
275
api/app/data/classes/wildstrider.yaml
Normal file
275
api/app/data/classes/wildstrider.yaml
Normal file
@@ -0,0 +1,275 @@
|
||||
# Wildstrider - Ranged Physical
|
||||
# Flexible hybrid class: Choose Marksmanship (precision ranged) or Beast Companion (pet damage)
|
||||
|
||||
class_id: wildstrider
|
||||
name: Wildstrider
|
||||
description: >
|
||||
A master of the wilds who excels at ranged combat and bonds with nature. Wildstriders
|
||||
can become elite marksmen with unmatched accuracy or beast masters commanding powerful
|
||||
animal companions. Choose your path: perfect your aim or unleash the wild.
|
||||
|
||||
# Base stats (total: 66)
|
||||
base_stats:
|
||||
strength: 10 # Average physical power
|
||||
dexterity: 14 # High agility
|
||||
constitution: 11 # Above average endurance
|
||||
intelligence: 9 # Below average magic
|
||||
wisdom: 13 # Above average perception
|
||||
charisma: 9 # Below average social
|
||||
|
||||
starting_equipment:
|
||||
- rusty_bow
|
||||
- cloth_armor
|
||||
- rusty_knife
|
||||
|
||||
starting_abilities:
|
||||
- basic_attack
|
||||
|
||||
skill_trees:
|
||||
# ==================== MARKSMANSHIP (Precision Ranged) ====================
|
||||
- tree_id: marksmanship
|
||||
name: Marksmanship
|
||||
description: >
|
||||
The path of the sharpshooter. Master the bow to deliver devastating precision
|
||||
strikes from afar, never missing your mark.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: aimed_shot
|
||||
name: Aimed Shot
|
||||
description: Take careful aim before firing, dealing increased damage with high accuracy.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- aimed_shot
|
||||
|
||||
- skill_id: steady_hand
|
||||
name: Steady Hand
|
||||
description: Your ranged accuracy improves through training.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 5
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: multishot
|
||||
name: Multishot
|
||||
description: Fire multiple arrows at once, hitting up to 3 targets.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- aimed_shot
|
||||
effects:
|
||||
abilities:
|
||||
- multishot
|
||||
|
||||
- skill_id: eagle_eye
|
||||
name: Eagle Eye
|
||||
description: Your perception sharpens, increasing critical hit chance with ranged weapons.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- steady_hand
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
ranged_crit_chance: 0.15 # +15% crit with ranged
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: piercing_shot
|
||||
name: Piercing Shot
|
||||
description: Fire an arrow that pierces through enemies, hitting all in a line.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- multishot
|
||||
effects:
|
||||
abilities:
|
||||
- piercing_shot
|
||||
|
||||
- skill_id: deadly_aim
|
||||
name: Deadly Aim
|
||||
description: Your arrows find vital spots with deadly precision.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- eagle_eye
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 10
|
||||
combat_bonuses:
|
||||
ranged_crit_chance: 0.10 # Additional +10% crit
|
||||
ranged_crit_multiplier: 0.5 # +0.5 to crit damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: explosive_shot
|
||||
name: Explosive Shot
|
||||
description: Fire an explosive arrow that detonates on impact, damaging nearby enemies.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- piercing_shot
|
||||
effects:
|
||||
abilities:
|
||||
- explosive_shot
|
||||
|
||||
- skill_id: master_archer
|
||||
name: Master Archer
|
||||
description: Your archery skills reach legendary status.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- deadly_aim
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 15
|
||||
combat_bonuses:
|
||||
ranged_damage_bonus: 0.25 # +25% ranged damage
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: rain_of_arrows
|
||||
name: Rain of Arrows
|
||||
description: Fire countless arrows into the sky that rain down on all enemies.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- explosive_shot
|
||||
effects:
|
||||
abilities:
|
||||
- rain_of_arrows
|
||||
|
||||
- skill_id: true_shot
|
||||
name: True Shot
|
||||
description: Every arrow finds its mark. Massive ranged combat bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- master_archer
|
||||
effects:
|
||||
stat_bonuses:
|
||||
dexterity: 20
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
ranged_damage_bonus: 0.50 # Additional +50% ranged damage
|
||||
ranged_crit_chance: 0.20 # Additional +20% crit
|
||||
|
||||
# ==================== BEAST COMPANION (Pet Damage) ====================
|
||||
- tree_id: beast_companion
|
||||
name: Beast Companion
|
||||
description: >
|
||||
The path of the beast master. Bond with a wild animal companion that fights
|
||||
alongside you, growing stronger as your connection deepens.
|
||||
|
||||
nodes:
|
||||
# --- TIER 1 ---
|
||||
- skill_id: summon_companion
|
||||
name: Summon Companion
|
||||
description: Call a loyal animal companion to fight by your side.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
abilities:
|
||||
- summon_companion
|
||||
|
||||
- skill_id: animal_bond
|
||||
name: Animal Bond
|
||||
description: Your connection with nature strengthens your companion.
|
||||
tier: 1
|
||||
prerequisites: []
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.15 # +15% pet damage
|
||||
|
||||
# --- TIER 2 ---
|
||||
- skill_id: coordinated_attack
|
||||
name: Coordinated Attack
|
||||
description: Command your companion to attack with you for combined damage.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- summon_companion
|
||||
effects:
|
||||
abilities:
|
||||
- coordinated_attack
|
||||
|
||||
- skill_id: feral_instinct
|
||||
name: Feral Instinct
|
||||
description: Your companion becomes more ferocious and resilient.
|
||||
tier: 2
|
||||
prerequisites:
|
||||
- animal_bond
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 5
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.20 # Additional +20% pet damage
|
||||
pet_health_bonus: 0.25 # +25% pet HP
|
||||
|
||||
# --- TIER 3 ---
|
||||
- skill_id: bestial_wrath
|
||||
name: Bestial Wrath
|
||||
description: Enrage your companion, drastically increasing its damage for 3 turns.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- coordinated_attack
|
||||
effects:
|
||||
abilities:
|
||||
- bestial_wrath
|
||||
|
||||
- skill_id: wild_empathy
|
||||
name: Wild Empathy
|
||||
description: Your bond with beasts allows your companion to grow stronger.
|
||||
tier: 3
|
||||
prerequisites:
|
||||
- feral_instinct
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.25 # Additional +25% pet damage
|
||||
|
||||
# --- TIER 4 ---
|
||||
- skill_id: primal_fury
|
||||
name: Primal Fury
|
||||
description: Your companion enters a primal rage, dealing massive damage to all enemies.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- bestial_wrath
|
||||
effects:
|
||||
abilities:
|
||||
- primal_fury
|
||||
|
||||
- skill_id: apex_predator
|
||||
name: Apex Predator
|
||||
description: Your companion becomes an apex predator, feared by all.
|
||||
tier: 4
|
||||
prerequisites:
|
||||
- wild_empathy
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 10
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 0.35 # Additional +35% pet damage
|
||||
pet_health_bonus: 0.50 # Additional +50% pet HP
|
||||
pet_crit_chance: 0.20 # +20% pet crit
|
||||
|
||||
# --- TIER 5 (Ultimate) ---
|
||||
- skill_id: stampede
|
||||
name: Stampede
|
||||
description: Summon a stampede of beasts that trample all enemies, dealing catastrophic damage.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- primal_fury
|
||||
effects:
|
||||
abilities:
|
||||
- stampede
|
||||
|
||||
- skill_id: one_with_the_wild
|
||||
name: One with the Wild
|
||||
description: You and your companion become one with nature. Incredible bonuses.
|
||||
tier: 5
|
||||
prerequisites:
|
||||
- apex_predator
|
||||
effects:
|
||||
stat_bonuses:
|
||||
wisdom: 20
|
||||
dexterity: 10
|
||||
combat_bonuses:
|
||||
pet_damage_bonus: 1.0 # Additional +100% pet damage (double damage!)
|
||||
pet_health_bonus: 1.0 # Additional +100% pet HP
|
||||
269
api/app/data/generic_items.yaml
Normal file
269
api/app/data/generic_items.yaml
Normal file
@@ -0,0 +1,269 @@
|
||||
# Generic Item Templates
|
||||
# These are common mundane items that the AI can give to players during gameplay.
|
||||
# They serve as templates for AI-generated items, providing consistent values
|
||||
# for simple items like torches, food, rope, etc.
|
||||
#
|
||||
# When the AI creates a generic item, the validator will look for a matching
|
||||
# template to use as defaults. Items not matching a template will be created
|
||||
# with the AI-provided values only.
|
||||
|
||||
templates:
|
||||
# Light sources
|
||||
torch:
|
||||
name: "Torch"
|
||||
description: "A wooden torch that provides light in dark places."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
lantern:
|
||||
name: "Lantern"
|
||||
description: "An oil lantern that provides steady light."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
candle:
|
||||
name: "Candle"
|
||||
description: "A simple wax candle."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Food and drink
|
||||
bread:
|
||||
name: "Bread"
|
||||
description: "A loaf of bread, possibly stale but still edible."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
apple:
|
||||
name: "Apple"
|
||||
description: "A fresh red apple."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
cheese:
|
||||
name: "Cheese"
|
||||
description: "A wedge of aged cheese."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
rations:
|
||||
name: "Rations"
|
||||
description: "A day's worth of preserved food."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
water:
|
||||
name: "Waterskin"
|
||||
description: "A leather waterskin filled with clean water."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
ale:
|
||||
name: "Ale"
|
||||
description: "A mug of common tavern ale."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
wine:
|
||||
name: "Wine"
|
||||
description: "A bottle of wine."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Tools and supplies
|
||||
rope:
|
||||
name: "Rope"
|
||||
description: "A sturdy length of hempen rope, about 50 feet."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
flint:
|
||||
name: "Flint and Steel"
|
||||
description: "A flint and steel for starting fires."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
bedroll:
|
||||
name: "Bedroll"
|
||||
description: "A simple bedroll for sleeping outdoors."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
backpack:
|
||||
name: "Backpack"
|
||||
description: "A sturdy canvas backpack."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
crowbar:
|
||||
name: "Crowbar"
|
||||
description: "An iron crowbar for prying things open."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
hammer:
|
||||
name: "Hammer"
|
||||
description: "A simple hammer."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
pitons:
|
||||
name: "Pitons"
|
||||
description: "A set of iron pitons for climbing."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
grappling_hook:
|
||||
name: "Grappling Hook"
|
||||
description: "A three-pronged iron grappling hook."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Writing supplies
|
||||
ink:
|
||||
name: "Ink"
|
||||
description: "A small vial of black ink."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
parchment:
|
||||
name: "Parchment"
|
||||
description: "A sheet of parchment for writing."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
quill:
|
||||
name: "Quill"
|
||||
description: "A feather quill for writing."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Containers
|
||||
pouch:
|
||||
name: "Pouch"
|
||||
description: "A small leather pouch."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
sack:
|
||||
name: "Sack"
|
||||
description: "A burlap sack for carrying goods."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
vial:
|
||||
name: "Empty Vial"
|
||||
description: "A small glass vial."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Clothing
|
||||
cloak:
|
||||
name: "Cloak"
|
||||
description: "A simple traveler's cloak."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
boots:
|
||||
name: "Boots"
|
||||
description: "A sturdy pair of leather boots."
|
||||
value: 8
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
gloves:
|
||||
name: "Gloves"
|
||||
description: "A pair of leather gloves."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Miscellaneous
|
||||
mirror:
|
||||
name: "Mirror"
|
||||
description: "A small steel mirror."
|
||||
value: 10
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
bell:
|
||||
name: "Bell"
|
||||
description: "A small brass bell."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
whistle:
|
||||
name: "Whistle"
|
||||
description: "A simple wooden whistle."
|
||||
value: 1
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
key:
|
||||
name: "Key"
|
||||
description: "A simple iron key."
|
||||
value: 5
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
map:
|
||||
name: "Map"
|
||||
description: "A rough map of the local area."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
compass:
|
||||
name: "Compass"
|
||||
description: "A magnetic compass for navigation."
|
||||
value: 20
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
# Simple consumables
|
||||
bandage:
|
||||
name: "Bandage"
|
||||
description: "A clean cloth bandage for basic wound care."
|
||||
value: 2
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
antidote:
|
||||
name: "Antidote"
|
||||
description: "A basic herbal remedy for common poisons."
|
||||
value: 15
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
|
||||
herbs:
|
||||
name: "Herbs"
|
||||
description: "A bundle of useful herbs."
|
||||
value: 3
|
||||
is_tradeable: true
|
||||
required_level: 1
|
||||
46
api/app/data/locations/crossville/crossville_crypt.yaml
Normal file
46
api/app/data/locations/crossville/crossville_crypt.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# The Forgotten Crypt - Ancient burial site
|
||||
location_id: crossville_crypt
|
||||
name: The Forgotten Crypt
|
||||
location_type: ruins
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
Hidden beneath a collapsed stone circle deep in Thornwood Forest lies an
|
||||
ancient crypt. The entrance, half-buried by centuries of accumulated earth
|
||||
and roots, leads down into darkness. Faded carvings on the weathered stones
|
||||
depict figures in robes performing unknown rituals around what appears to be
|
||||
a great black sun.
|
||||
|
||||
lore: |
|
||||
Long before Crossville was founded, before even the elves came to these lands,
|
||||
another civilization built monuments to their strange gods. The Forgotten Crypt
|
||||
is one of their burial sites - a place where priest-kings were interred with
|
||||
their servants and treasures. Local legends warn that the dead here do not
|
||||
rest peacefully, and that disturbing their tombs invites a terrible curse.
|
||||
|
||||
ambient_description: |
|
||||
The air in the crypt is stale and cold, carrying the musty scent of ancient
|
||||
decay. What little light enters through the broken ceiling reveals dust motes
|
||||
floating in perfectly still air. Stone sarcophagi line the walls, their lids
|
||||
carved with the faces of the long-dead. Some lids have been displaced,
|
||||
revealing empty darkness within. The silence is absolute - even footsteps
|
||||
seem muffled, as if the crypt itself absorbs sound.
|
||||
|
||||
available_quests:
|
||||
- quest_undead_menace
|
||||
- quest_ancient_relic
|
||||
- quest_necromancer_lair
|
||||
|
||||
npc_ids: []
|
||||
|
||||
discoverable_locations: []
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- ruins
|
||||
- dangerous
|
||||
- undead
|
||||
- treasure
|
||||
- mystery
|
||||
- boss
|
||||
47
api/app/data/locations/crossville/crossville_dungeon.yaml
Normal file
47
api/app/data/locations/crossville/crossville_dungeon.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# The Old Mines - Abandoned dungeon beneath the hills
|
||||
location_id: crossville_dungeon
|
||||
name: The Old Mines
|
||||
location_type: dungeon
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A network of abandoned mine tunnels carved into the hills north of Crossville.
|
||||
The mines were sealed decades ago after a cave-in killed a dozen workers, but
|
||||
the entrance has recently been found open. Strange sounds echo from the depths,
|
||||
and the few who have ventured inside speak of unnatural creatures lurking in
|
||||
the darkness.
|
||||
|
||||
lore: |
|
||||
The mines were originally dug by dwarven prospectors seeking iron ore. They
|
||||
found more than iron - ancient carvings deep in the tunnels suggest something
|
||||
else was buried here long ago. The cave-in that sealed the mines was blamed
|
||||
on unstable rock, but survivors whispered of something awakening in the deep.
|
||||
The mine was sealed and the entrance forbidden, until recent earthquakes
|
||||
reopened the way.
|
||||
|
||||
ambient_description: |
|
||||
The mine entrance yawns like a mouth in the hillside, exhaling cold air that
|
||||
smells of wet stone and something older. Rotting timber supports the first
|
||||
few feet of tunnel, beyond which darkness swallows everything. Somewhere
|
||||
in the depths, water drips with metronomic regularity. Occasionally, other
|
||||
sounds echo up from below - scraping, shuffling, or what might be whispered
|
||||
voices.
|
||||
|
||||
available_quests:
|
||||
- quest_mine_exploration
|
||||
- quest_lost_miners
|
||||
- quest_ancient_artifact
|
||||
|
||||
npc_ids: []
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_crypt
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- dungeon
|
||||
- dangerous
|
||||
- combat
|
||||
- treasure
|
||||
- mystery
|
||||
45
api/app/data/locations/crossville/crossville_forest.yaml
Normal file
45
api/app/data/locations/crossville/crossville_forest.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Thornwood Forest - Wilderness area east of village
|
||||
location_id: crossville_forest
|
||||
name: Thornwood Forest
|
||||
location_type: wilderness
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A dense woodland stretching east from Crossville, named for the thorny
|
||||
undergrowth that makes travel off the main path treacherous. Ancient oaks
|
||||
and twisted pines block much of the sunlight, creating an perpetual twilight
|
||||
beneath the canopy. The eastern trade road cuts through here, though bandits
|
||||
have made it increasingly dangerous.
|
||||
|
||||
lore: |
|
||||
The Thornwood is said to be as old as the mountains themselves. Local legend
|
||||
speaks of an ancient elven settlement deep within the forest, though no one
|
||||
has found it in living memory. What is certain is that the forest hides many
|
||||
secrets - ancient ruins, hidden caves, and creatures that prefer darkness
|
||||
to light. Hunters know to return before nightfall.
|
||||
|
||||
ambient_description: |
|
||||
Shafts of pale light filter through the canopy, illuminating swirling motes
|
||||
of dust and pollen. The forest floor is carpeted with fallen leaves and
|
||||
treacherous roots. Birds call from the branches above, falling silent
|
||||
whenever something large moves through the underbrush. The air is thick
|
||||
with the scent of damp earth and decaying vegetation.
|
||||
|
||||
available_quests:
|
||||
- quest_bandit_camp
|
||||
- quest_herb_gathering
|
||||
- quest_lost_traveler
|
||||
|
||||
npc_ids: []
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_dungeon
|
||||
- crossville_crypt
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- wilderness
|
||||
- dangerous
|
||||
- exploration
|
||||
- hunting
|
||||
47
api/app/data/locations/crossville/crossville_tavern.yaml
Normal file
47
api/app/data/locations/crossville/crossville_tavern.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# The Rusty Anchor Tavern - Social hub of Crossville
|
||||
location_id: crossville_tavern
|
||||
name: The Rusty Anchor Tavern
|
||||
location_type: tavern
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A weathered two-story establishment at the heart of Crossville Village.
|
||||
The wooden sign creaks in the wind, depicting a rusted ship's anchor - an
|
||||
odd choice for a landlocked village. Inside, travelers and locals alike
|
||||
gather around rough-hewn tables to share drinks, stories, and rumors.
|
||||
|
||||
lore: |
|
||||
Founded eighty years ago by a retired sailor named Captain Morgath, the
|
||||
Rusty Anchor has served as Crossville's social hub for generations.
|
||||
The captain's old anchor hangs above the fireplace, supposedly recovered
|
||||
from a shipwreck that cost him his crew. Many adventurers have planned
|
||||
expeditions over tankards of the house special - a dark ale brewed from
|
||||
a secret recipe the captain brought from the coast.
|
||||
|
||||
ambient_description: |
|
||||
The tavern interior is warm and dimly lit by oil lamps and the glow of
|
||||
the stone hearth. Pipe smoke hangs in lazy clouds above the regulars'
|
||||
corner. The air smells of ale, roasted meat, and wood polish. A bard
|
||||
occasionally plucks at a lute in the corner, though tonight the only
|
||||
music is the murmur of conversation and the crackle of the fire.
|
||||
|
||||
available_quests:
|
||||
- quest_cellar_rats
|
||||
- quest_missing_shipment
|
||||
|
||||
npc_ids:
|
||||
- npc_grom_ironbeard
|
||||
- npc_mira_swiftfoot
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_dungeon
|
||||
- crossville_forest
|
||||
|
||||
is_starting_location: false
|
||||
|
||||
tags:
|
||||
- social
|
||||
- rest
|
||||
- rumors
|
||||
- merchant
|
||||
- information
|
||||
44
api/app/data/locations/crossville/crossville_village.yaml
Normal file
44
api/app/data/locations/crossville/crossville_village.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
# Crossville Village - The main settlement
|
||||
location_id: crossville_village
|
||||
name: Crossville Village
|
||||
location_type: town
|
||||
region_id: crossville
|
||||
|
||||
description: |
|
||||
A modest farming village built around a central square where several roads
|
||||
meet. Stone and timber buildings line the main street, with the mayor's
|
||||
manor overlooking the square from a small hill. Farmers sell produce at
|
||||
market stalls while merchants hawk wares from distant lands.
|
||||
|
||||
lore: |
|
||||
Founded two centuries ago by settlers from the eastern kingdoms, Crossville
|
||||
grew from a simple waystation into a thriving village. The original stone
|
||||
well in the center of the square is said to have been blessed by a traveling
|
||||
cleric, and the village has never suffered drought since.
|
||||
|
||||
ambient_description: |
|
||||
The village square bustles with activity - farmers haggling over prices,
|
||||
children running between market stalls, and the rhythmic clang of the
|
||||
blacksmith's hammer echoing from his forge. The smell of fresh bread
|
||||
drifts from the bakery, mixing with the earthier scents of livestock
|
||||
and hay.
|
||||
|
||||
available_quests:
|
||||
- quest_mayors_request
|
||||
- quest_missing_merchant
|
||||
|
||||
npc_ids:
|
||||
- npc_mayor_aldric
|
||||
- npc_blacksmith_hilda
|
||||
|
||||
discoverable_locations:
|
||||
- crossville_tavern
|
||||
- crossville_forest
|
||||
|
||||
is_starting_location: true
|
||||
|
||||
tags:
|
||||
- town
|
||||
- social
|
||||
- merchant
|
||||
- safe
|
||||
16
api/app/data/locations/regions/crossville.yaml
Normal file
16
api/app/data/locations/regions/crossville.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Crossville Region - Starting area for new adventurers
|
||||
region_id: crossville
|
||||
name: Crossville Province
|
||||
description: |
|
||||
A quiet farming province on the frontier of the kingdom. Crossville sits at
|
||||
the crossroads of several trade routes, making it a natural gathering point
|
||||
for travelers, merchants, and those seeking adventure. The village has
|
||||
prospered from this trade, though recent bandit activity has made the roads
|
||||
less safe than they once were.
|
||||
|
||||
location_ids:
|
||||
- crossville_village
|
||||
- crossville_tavern
|
||||
- crossville_forest
|
||||
- crossville_dungeon
|
||||
- crossville_crypt
|
||||
281
api/app/data/loot_tables.yaml
Normal file
281
api/app/data/loot_tables.yaml
Normal file
@@ -0,0 +1,281 @@
|
||||
# Loot Tables
|
||||
# Defines what items can be found when searching in different locations.
|
||||
# Items are referenced by their template key from generic_items.yaml.
|
||||
#
|
||||
# Rarity tiers determine selection based on check margin:
|
||||
# - common: margin < 5 (just barely passed)
|
||||
# - uncommon: margin 5-9 (solid success)
|
||||
# - rare: margin >= 10 (excellent roll)
|
||||
#
|
||||
# Gold ranges are also determined by margin.
|
||||
|
||||
# Default loot for unspecified locations
|
||||
default:
|
||||
common:
|
||||
- torch
|
||||
- flint
|
||||
- rope
|
||||
- rations
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- bandage
|
||||
- herbs
|
||||
rare:
|
||||
- compass
|
||||
- map
|
||||
- antidote
|
||||
gold:
|
||||
min: 1
|
||||
max: 10
|
||||
bonus_per_margin: 1 # Extra gold per margin point
|
||||
|
||||
# Forest/wilderness locations
|
||||
forest:
|
||||
common:
|
||||
- herbs
|
||||
- apple
|
||||
- flint
|
||||
- rope
|
||||
uncommon:
|
||||
- rations
|
||||
- antidote
|
||||
- bandage
|
||||
- water
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- grappling_hook
|
||||
gold:
|
||||
min: 0
|
||||
max: 5
|
||||
bonus_per_margin: 0
|
||||
|
||||
# Cave/dungeon locations
|
||||
cave:
|
||||
common:
|
||||
- torch
|
||||
- flint
|
||||
- rope
|
||||
- pitons
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- grappling_hook
|
||||
- bandage
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- key
|
||||
gold:
|
||||
min: 5
|
||||
max: 25
|
||||
bonus_per_margin: 2
|
||||
|
||||
dungeon:
|
||||
common:
|
||||
- torch
|
||||
- key
|
||||
- rope
|
||||
- bandage
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- antidote
|
||||
- map
|
||||
rare:
|
||||
- compass
|
||||
- grappling_hook
|
||||
- mirror
|
||||
gold:
|
||||
min: 10
|
||||
max: 50
|
||||
bonus_per_margin: 3
|
||||
|
||||
# Town/city locations
|
||||
town:
|
||||
common:
|
||||
- bread
|
||||
- apple
|
||||
- ale
|
||||
- candle
|
||||
uncommon:
|
||||
- cheese
|
||||
- wine
|
||||
- rations
|
||||
- parchment
|
||||
rare:
|
||||
- map
|
||||
- ink
|
||||
- quill
|
||||
gold:
|
||||
min: 2
|
||||
max: 15
|
||||
bonus_per_margin: 1
|
||||
|
||||
tavern:
|
||||
common:
|
||||
- bread
|
||||
- cheese
|
||||
- ale
|
||||
- candle
|
||||
uncommon:
|
||||
- wine
|
||||
- rations
|
||||
- water
|
||||
- key
|
||||
rare:
|
||||
- map
|
||||
- pouch
|
||||
- mirror
|
||||
gold:
|
||||
min: 3
|
||||
max: 20
|
||||
bonus_per_margin: 2
|
||||
|
||||
# Ruins/ancient locations
|
||||
ruins:
|
||||
common:
|
||||
- torch
|
||||
- parchment
|
||||
- vial
|
||||
- rope
|
||||
uncommon:
|
||||
- ink
|
||||
- quill
|
||||
- mirror
|
||||
- key
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- antidote
|
||||
gold:
|
||||
min: 10
|
||||
max: 40
|
||||
bonus_per_margin: 3
|
||||
|
||||
# Camp/outdoor locations
|
||||
camp:
|
||||
common:
|
||||
- rations
|
||||
- water
|
||||
- bedroll
|
||||
- flint
|
||||
uncommon:
|
||||
- rope
|
||||
- torch
|
||||
- bandage
|
||||
- sack
|
||||
rare:
|
||||
- lantern
|
||||
- backpack
|
||||
- map
|
||||
gold:
|
||||
min: 1
|
||||
max: 10
|
||||
bonus_per_margin: 1
|
||||
|
||||
# Merchant/shop locations
|
||||
shop:
|
||||
common:
|
||||
- pouch
|
||||
- sack
|
||||
- candle
|
||||
- parchment
|
||||
uncommon:
|
||||
- ink
|
||||
- quill
|
||||
- vial
|
||||
- key
|
||||
rare:
|
||||
- map
|
||||
- mirror
|
||||
- compass
|
||||
gold:
|
||||
min: 5
|
||||
max: 30
|
||||
bonus_per_margin: 2
|
||||
|
||||
# Road/path locations
|
||||
road:
|
||||
common:
|
||||
- rope
|
||||
- flint
|
||||
- water
|
||||
- bread
|
||||
uncommon:
|
||||
- bandage
|
||||
- rations
|
||||
- torch
|
||||
- boots
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- cloak
|
||||
gold:
|
||||
min: 1
|
||||
max: 15
|
||||
bonus_per_margin: 1
|
||||
|
||||
# Castle/fortress locations
|
||||
castle:
|
||||
common:
|
||||
- torch
|
||||
- candle
|
||||
- key
|
||||
- parchment
|
||||
uncommon:
|
||||
- lantern
|
||||
- ink
|
||||
- quill
|
||||
- mirror
|
||||
rare:
|
||||
- map
|
||||
- compass
|
||||
- crowbar
|
||||
gold:
|
||||
min: 15
|
||||
max: 60
|
||||
bonus_per_margin: 4
|
||||
|
||||
# Dock/port locations
|
||||
dock:
|
||||
common:
|
||||
- rope
|
||||
- water
|
||||
- rations
|
||||
- sack
|
||||
uncommon:
|
||||
- grappling_hook
|
||||
- lantern
|
||||
- map
|
||||
- flint
|
||||
rare:
|
||||
- compass
|
||||
- backpack
|
||||
- cloak
|
||||
gold:
|
||||
min: 5
|
||||
max: 25
|
||||
bonus_per_margin: 2
|
||||
|
||||
# Mine locations
|
||||
mine:
|
||||
common:
|
||||
- torch
|
||||
- pitons
|
||||
- rope
|
||||
- hammer
|
||||
uncommon:
|
||||
- lantern
|
||||
- crowbar
|
||||
- flint
|
||||
- bandage
|
||||
rare:
|
||||
- grappling_hook
|
||||
- map
|
||||
- key
|
||||
gold:
|
||||
min: 15
|
||||
max: 50
|
||||
bonus_per_margin: 3
|
||||
93
api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml
Normal file
93
api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
# Hilda Ironforge - Village Blacksmith
|
||||
npc_id: npc_blacksmith_hilda
|
||||
name: Hilda Ironforge
|
||||
role: blacksmith
|
||||
location_id: crossville_village
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- straightforward
|
||||
- hardworking
|
||||
- proud of her craft
|
||||
- protective of the village
|
||||
- stubborn as iron
|
||||
speech_style: |
|
||||
Blunt and direct - says what she means without flourish. Her voice
|
||||
carries the confidence of someone who knows their worth. Uses
|
||||
smithing metaphors often ("hammer out the details," "strike while
|
||||
hot"). Speaks slowly and deliberately, each word carrying weight.
|
||||
quirks:
|
||||
- Absent-mindedly hammers on things when thinking
|
||||
- Inspects every weapon she sees for quality
|
||||
- Refuses to sell poorly-made goods even at high prices
|
||||
- Hums dwarven work songs while forging
|
||||
|
||||
appearance:
|
||||
brief: Muscular dwarven woman with soot-streaked red hair, burn-scarred forearms, and an appraising gaze
|
||||
detailed: |
|
||||
Hilda is built like a forge - solid, hot-tempered, and productive.
|
||||
Her red hair is pulled back in a practical braid, streaked with
|
||||
grey and permanently dusted with soot. Her forearms are a map of
|
||||
old burns and calluses, badges of honor in her trade. She wears a
|
||||
leather apron over practical clothes, and her hands are never far
|
||||
from a hammer. Her eyes assess everything with the critical gaze of
|
||||
a master craftsman, always noting quality - or its lack.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- The best iron ore came from the Old Mines before they were sealed
|
||||
- She can repair almost anything made of metal
|
||||
- Bandit attacks have increased demand for weapons
|
||||
- Her family has been smithing in Crossville for four generations
|
||||
secret:
|
||||
- Her grandfather forged something for the previous mayor - something that was buried
|
||||
- She has the original designs for that artifact
|
||||
- The ore in the mines had unusual properties - made metal stronger
|
||||
- She suspects the bandits are looking for her grandfather's work
|
||||
will_share_if:
|
||||
- condition: "interaction_count >= 4"
|
||||
reveals: "Mentions her grandfather worked on a special project for the mayor's family"
|
||||
- condition: "custom_flags.brought_quality_ore == true"
|
||||
reveals: "Shares that the mine ore was special - almost magical"
|
||||
- condition: "relationship_level >= 75"
|
||||
reveals: "Shows them her grandfather's old designs"
|
||||
- condition: "custom_flags.proved_worthy_warrior == true"
|
||||
reveals: "Offers to forge them something special if they find the right materials"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_grom_ironbeard
|
||||
attitude: friendly
|
||||
reason: Old drinking companions and fellow dwarves
|
||||
- npc_id: npc_mayor_aldric
|
||||
attitude: respectful but curious
|
||||
reason: The Thornwood family has secrets connected to her own
|
||||
|
||||
inventory_for_sale:
|
||||
- item: sword_iron
|
||||
price: 50
|
||||
- item: shield_iron
|
||||
price: 40
|
||||
- item: armor_chainmail
|
||||
price: 150
|
||||
- item: dagger_steel
|
||||
price: 25
|
||||
- item: repair_service
|
||||
price: 20
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*sets down hammer* Something you need forged, or just looking?"
|
||||
farewell: "May your blade stay sharp and your armor hold."
|
||||
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_giver_for:
|
||||
- quest_ore_delivery
|
||||
- quest_equipment_repair
|
||||
|
||||
reveals_locations: []
|
||||
|
||||
tags:
|
||||
- merchant
|
||||
- quest_giver
|
||||
- craftsman
|
||||
- dwarf
|
||||
95
api/app/data/npcs/crossville/npc_grom_ironbeard.yaml
Normal file
95
api/app/data/npcs/crossville/npc_grom_ironbeard.yaml
Normal file
@@ -0,0 +1,95 @@
|
||||
# Grom Ironbeard - Tavern Bartender
|
||||
npc_id: npc_grom_ironbeard
|
||||
name: Grom Ironbeard
|
||||
role: bartender
|
||||
location_id: crossville_tavern
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- gruff
|
||||
- secretly kind
|
||||
- protective of regulars
|
||||
- distrustful of strangers
|
||||
- nostalgic about his adventuring days
|
||||
speech_style: |
|
||||
Short, clipped sentences. Heavy dwarvish accent - often drops articles
|
||||
("Need a drink?" becomes "Need drink?"). Speaks in a gravelly baritone.
|
||||
Uses "lad" and "lass" frequently. Never raises his voice unless truly angry.
|
||||
quirks:
|
||||
- Polishes the same glass when nervous or thinking
|
||||
- Tugs his beard when considering something seriously
|
||||
- Refuses to serve anyone who insults his ale
|
||||
- Hums old mining songs when the tavern is quiet
|
||||
|
||||
appearance:
|
||||
brief: Stocky dwarf with a braided grey beard, one clouded eye, and arms like tree trunks
|
||||
detailed: |
|
||||
Standing barely four feet tall, Grom's broad shoulders and thick arms
|
||||
speak to decades of barrel-lifting and troublemaker-throwing. His grey
|
||||
beard is immaculately braided with copper rings passed down from his
|
||||
grandfather. A milky cataract clouds his left eye - a souvenir from his
|
||||
adventuring days - but his right eye misses nothing that happens in his
|
||||
tavern. His apron is always clean, though his hands bear the calluses
|
||||
of hard work.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- Local gossip about Mayor Aldric raising taxes again
|
||||
- The road east through Thornwood has been plagued by bandits
|
||||
- A traveling merchant was asking about ancient ruins last week
|
||||
- The blacksmith Hilda needs more iron ore but the mines are sealed
|
||||
secret:
|
||||
- Hidden passage behind the wine barrels leads to old smuggling tunnels
|
||||
- The mayor is being blackmailed by someone - he's seen the letters
|
||||
- Knows the location of a legendary dwarven forge in the mountains
|
||||
- The cave-in in the mines wasn't natural - something broke through from below
|
||||
will_share_if:
|
||||
- condition: "interaction_count >= 3"
|
||||
reveals: "Mentions he used to be an adventurer who explored the Old Mines"
|
||||
- condition: "custom_flags.helped_with_rowdy_patrons == true"
|
||||
reveals: "Shows them the hidden passage behind the wine barrels"
|
||||
- condition: "relationship_level >= 70"
|
||||
reveals: "Confides about the mayor's blackmail situation"
|
||||
- condition: "relationship_level >= 85"
|
||||
reveals: "Shares the location of the dwarven forge"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_mayor_aldric
|
||||
attitude: distrustful
|
||||
reason: Mayor raised tavern taxes unfairly and seems nervous lately
|
||||
- npc_id: npc_mira_swiftfoot
|
||||
attitude: protective
|
||||
reason: She reminds him of his daughter who died young
|
||||
- npc_id: npc_blacksmith_hilda
|
||||
attitude: friendly
|
||||
reason: Fellow dwarf and drinking companion for decades
|
||||
|
||||
inventory_for_sale:
|
||||
- item: ale
|
||||
price: 2
|
||||
- item: dwarven_stout
|
||||
price: 5
|
||||
- item: meal_hearty
|
||||
price: 8
|
||||
- item: room_night
|
||||
price: 15
|
||||
- item: information_local
|
||||
price: 10
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*grunts* What'll it be? And don't waste my time."
|
||||
farewell: "*nods* Don't cause trouble out there."
|
||||
busy: "Got thirsty folk to serve. Make it quick."
|
||||
quest_complete: "*actually smiles* Well done, lad. Drink's on the house."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_cellar_rats
|
||||
|
||||
reveals_locations:
|
||||
- crossville_dungeon
|
||||
|
||||
tags:
|
||||
- merchant
|
||||
- quest_giver
|
||||
- information_source
|
||||
- dwarf
|
||||
83
api/app/data/npcs/crossville/npc_mayor_aldric.yaml
Normal file
83
api/app/data/npcs/crossville/npc_mayor_aldric.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
# Mayor Aldric Thornwood - Village Leader
|
||||
npc_id: npc_mayor_aldric
|
||||
name: Mayor Aldric Thornwood
|
||||
role: mayor
|
||||
location_id: crossville_village
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- outwardly confident
|
||||
- secretly terrified
|
||||
- genuinely cares about the village
|
||||
- increasingly desperate
|
||||
- hiding something significant
|
||||
speech_style: |
|
||||
Speaks with the practiced cadence of a politician - measured words,
|
||||
careful pauses for effect. His voice wavers slightly when stressed,
|
||||
and he has a habit of clearing his throat before difficult topics.
|
||||
Uses formal address even in casual conversation.
|
||||
quirks:
|
||||
- Constantly adjusts his mayoral chain of office
|
||||
- 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:
|
||||
brief: Tall, thin man with receding grey hair, worry lines, and expensive but slightly disheveled clothing
|
||||
detailed: |
|
||||
Mayor Aldric carries himself with the posture of authority, though
|
||||
lately that posture has developed a slight stoop. His grey hair,
|
||||
once meticulously combed, shows signs of distracted neglect. His
|
||||
clothes are fine but wrinkled, and dark circles under his eyes
|
||||
suggest many sleepless nights. The heavy gold chain of his office
|
||||
seems to weigh on him more than it should. His hands tremble
|
||||
slightly when he thinks no one is watching.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- The village has prospered under his ten-year leadership
|
||||
- Taxes were raised to fund road repairs and militia expansion
|
||||
- He's offering a reward for clearing the bandit threat
|
||||
- The Old Mines are sealed for safety reasons
|
||||
secret:
|
||||
- He's being blackmailed by someone who knows about the mines
|
||||
- His grandfather found something in the mines that should stay buried
|
||||
- The blackmailer wants access to the crypt
|
||||
- He knows the earthquake that reopened the mines wasn't natural
|
||||
will_share_if:
|
||||
- condition: "relationship_level >= 60"
|
||||
reveals: "Admits the tax increase was forced by external pressure"
|
||||
- condition: "custom_flags.proved_trustworthy == true"
|
||||
reveals: "Confesses he's being blackmailed but won't say by whom"
|
||||
- condition: "relationship_level >= 80"
|
||||
reveals: "Shares his grandfather's journal about the mines"
|
||||
- condition: "custom_flags.defeated_blackmailer == true"
|
||||
reveals: "Reveals everything about what's buried in the crypt"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_grom_ironbeard
|
||||
attitude: guilty
|
||||
reason: Knows the tax increase hurt the tavern unfairly
|
||||
- npc_id: npc_blacksmith_hilda
|
||||
attitude: respectful
|
||||
reason: Her family has served the village for generations
|
||||
|
||||
inventory_for_sale: []
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*straightens his chain* Ah, welcome to Crossville. How may I be of service?"
|
||||
farewell: "The village thanks you. May your roads be safe."
|
||||
busy: "*distracted* I have urgent matters to attend. Perhaps later?"
|
||||
quest_complete: "*genuine relief* You have done Crossville a great service."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_mayors_request
|
||||
- quest_bandit_threat
|
||||
|
||||
reveals_locations:
|
||||
- crossville_dungeon
|
||||
|
||||
tags:
|
||||
- quest_giver
|
||||
- authority
|
||||
- human
|
||||
90
api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml
Normal file
90
api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
# Mira Swiftfoot - Rogue and Information Broker
|
||||
npc_id: npc_mira_swiftfoot
|
||||
name: Mira Swiftfoot
|
||||
role: rogue
|
||||
location_id: crossville_tavern
|
||||
|
||||
personality:
|
||||
traits:
|
||||
- curious
|
||||
- street-smart
|
||||
- morally flexible
|
||||
- loyal once trust is earned
|
||||
- haunted by her past
|
||||
speech_style: |
|
||||
Quick and clever, often speaking in half-sentences as if her mouth
|
||||
can't keep up with her racing thoughts. Uses thieves' cant occasionally.
|
||||
Tends to deflect personal questions with humor or questions of her own.
|
||||
Her voice drops to a whisper when sharing secrets.
|
||||
quirks:
|
||||
- Always sits with her back to the wall, facing the door
|
||||
- Fidgets with a coin, rolling it across her knuckles
|
||||
- Sizes up everyone who enters the tavern
|
||||
- Never drinks anything she didn't pour herself
|
||||
|
||||
appearance:
|
||||
brief: Slender half-elf with sharp green eyes, dark hair cut short, and fingers that never stop moving
|
||||
detailed: |
|
||||
Mira moves with the easy grace of someone used to slipping through
|
||||
shadows unnoticed. Her dark hair is cut practically short, framing
|
||||
an angular face with sharp green eyes that seem to catalog everything
|
||||
they see. She dresses in muted colors - browns and greys that blend
|
||||
into any crowd. A thin scar runs from her left ear to her jaw, and
|
||||
she wears leather bracers that probably hide more than calluses.
|
||||
|
||||
knowledge:
|
||||
public:
|
||||
- The bandits in Thornwood are more organized than simple thieves
|
||||
- There's a fence in the city who buys no-questions-asked
|
||||
- The mayor's been receiving mysterious visitors at night
|
||||
- Several people have gone missing in the forest lately
|
||||
secret:
|
||||
- The bandit leader is a former soldier named Kael
|
||||
- She knows a secret entrance to the crypt through the forest
|
||||
- The missing people were all asking about the Old Mines
|
||||
- She's actually running from a thieves' guild she betrayed
|
||||
will_share_if:
|
||||
- condition: "interaction_count >= 2"
|
||||
reveals: "Mentions the bandits seem to be searching for something specific"
|
||||
- condition: "custom_flags.shared_drink == true"
|
||||
reveals: "Admits she knows more about the forest than most"
|
||||
- condition: "relationship_level >= 65"
|
||||
reveals: "Reveals she knows a secret path to the crypt"
|
||||
- condition: "relationship_level >= 80"
|
||||
reveals: "Tells them about Kael and offers to help infiltrate the bandits"
|
||||
|
||||
relationships:
|
||||
- npc_id: npc_grom_ironbeard
|
||||
attitude: affectionate
|
||||
reason: He's the closest thing to family she has
|
||||
- npc_id: npc_mayor_aldric
|
||||
attitude: suspicious
|
||||
reason: Something about him doesn't add up
|
||||
|
||||
inventory_for_sale:
|
||||
- item: lockpick_set
|
||||
price: 25
|
||||
- item: rope_silk
|
||||
price: 15
|
||||
- item: map_local
|
||||
price: 20
|
||||
|
||||
dialogue_hooks:
|
||||
greeting: "*looks you over* New face. What brings you to our little crossroads?"
|
||||
farewell: "Watch your back out there. Trust me on that."
|
||||
busy: "*glances at the door* Not now. Later."
|
||||
quest_complete: "*grins* You've got potential. Stick around."
|
||||
|
||||
quest_giver_for:
|
||||
- quest_bandit_camp
|
||||
|
||||
reveals_locations:
|
||||
- crossville_forest
|
||||
- crossville_crypt
|
||||
|
||||
tags:
|
||||
- information_source
|
||||
- merchant
|
||||
- quest_giver
|
||||
- rogue
|
||||
- half-elf
|
||||
158
api/app/data/origins.yaml
Normal file
158
api/app/data/origins.yaml
Normal file
@@ -0,0 +1,158 @@
|
||||
# Character Origin Stories
|
||||
# These are saved to the character and referenced by the AI DM throughout the game
|
||||
# to create personalized narrative experiences and quest hooks.
|
||||
|
||||
origins:
|
||||
soul_revenant:
|
||||
id: soul_revenant
|
||||
name: Soul Revenant
|
||||
description: |
|
||||
You died centuries ago, but death was not the end. Through dark magic, divine
|
||||
intervention, or a cosmic mistake, you have been returned to the world of the
|
||||
living. Your memories are fragmented—flashes of a life long past, faces you once
|
||||
knew now turned to dust, and deeds both noble and terrible that weigh upon your soul.
|
||||
|
||||
The world has changed beyond recognition. The kingdom you served no longer exists,
|
||||
the people you loved are gone, and the wrongs you committed—or suffered—can never
|
||||
be undone. Yet here you stand, given a second chance you never asked for.
|
||||
|
||||
You awaken in an ancient crypt, your body restored but your purpose unclear.
|
||||
Are you here to atone? To finish unfinished business? Or simply to understand
|
||||
why you were brought back?
|
||||
|
||||
starting_location:
|
||||
id: forgotten_crypt
|
||||
name: The Forgotten Crypt
|
||||
region: Shadowmere Necropolis
|
||||
description: Ancient burial grounds beneath twisted trees, where the veil between life and death grows thin
|
||||
|
||||
narrative_hooks:
|
||||
- Past lives and forgotten memories that surface during gameplay
|
||||
- NPCs or descendants related to your previous life
|
||||
- Unfinished business from centuries ago
|
||||
- Haunted by spectral visions or voices from the past
|
||||
- Divine or dark entities interested in your return
|
||||
- Questions about identity and purpose across lifetimes
|
||||
|
||||
starting_bonus:
|
||||
trait: Deathless Resolve
|
||||
description: You have walked through death itself. Fear holds less power over you.
|
||||
effect: +2 WIS, resistance to fear effects
|
||||
|
||||
memory_thief:
|
||||
id: memory_thief
|
||||
name: Memory Thief
|
||||
description: |
|
||||
You opened your eyes in an open field with no memory of who you are, where you
|
||||
came from, or how you got here. Your mind is a blank slate—no name, no past,
|
||||
no identity. Even your own face in a reflection seems like a stranger's.
|
||||
|
||||
The only thing you possess is an overwhelming sense that something was taken
|
||||
from you. Your memories weren't simply lost—they were stolen. By whom? Why?
|
||||
You don't know. But deep in your gut, you feel that discovering the truth might
|
||||
be more terrifying than living in ignorance.
|
||||
|
||||
As you wander the world, fragments occasionally surface—a fleeting image, a
|
||||
half-remembered name, a skill you didn't know you had. Are these clues to your
|
||||
real identity, or false memories planted by whoever stole your past?
|
||||
|
||||
One thing is certain: you must piece together who you were, even if you discover
|
||||
you were someone you'd rather not remember.
|
||||
|
||||
starting_location:
|
||||
id: thornfield_plains
|
||||
name: Thornfield Plains
|
||||
region: The Midlands
|
||||
description: Vast open grasslands where merchant roads cross, a place of new beginnings for the lost
|
||||
|
||||
narrative_hooks:
|
||||
- Gradual memory fragments revealed during key story moments
|
||||
- NPCs who seem to recognize you but you don't remember them
|
||||
- Clues to your stolen past hidden in the world
|
||||
- A mysterious organization or individual who took your memories
|
||||
- Skills or knowledge you possess without knowing why
|
||||
- Identity crisis as you discover who you might have been
|
||||
|
||||
starting_bonus:
|
||||
trait: Blank Slate
|
||||
description: Without a past to define you, you adapt quickly to new situations.
|
||||
effect: +1 to all stats, faster skill learning
|
||||
|
||||
shadow_apprentice:
|
||||
id: shadow_apprentice
|
||||
name: Shadow Apprentice
|
||||
description: |
|
||||
You were raised in darkness—literally and figuratively. From childhood, you were
|
||||
trained by a mysterious master who taught you the arts of stealth, deception,
|
||||
and survival in the underworld. You learned to move unseen, to read people's
|
||||
secrets in their eyes, and to trust no one.
|
||||
|
||||
Your master never revealed why they chose you, only that you had "potential."
|
||||
For years, you honed your skills in the shadows, taking on jobs that required
|
||||
discretion and ruthlessness. You became a weapon in your master's hands.
|
||||
|
||||
But recently, everything changed. Your master disappeared without a word, leaving
|
||||
you with only your training and a single cryptic message: "Trust no one. Not even me."
|
||||
|
||||
Now you walk alone, unsure if your master abandoned you, was captured, or is
|
||||
testing you one final time. The underworld you once navigated so confidently
|
||||
suddenly feels hostile and full of eyes watching from the darkness.
|
||||
|
||||
starting_location:
|
||||
id: shadowfen
|
||||
name: Shadowfen
|
||||
region: The Murkvale
|
||||
description: A misty swamp settlement where outlaws and exiles gather, hidden from the world's judging eyes
|
||||
|
||||
narrative_hooks:
|
||||
- The mysterious master and their true motivations
|
||||
- Dark organizations or guilds from your past
|
||||
- Moral dilemmas between loyalty and self-preservation
|
||||
- Rivals or enemies from your apprenticeship
|
||||
- Secrets your master never told you
|
||||
- The true reason you were chosen and trained
|
||||
|
||||
starting_bonus:
|
||||
trait: Trained in Shadows
|
||||
description: Your master taught you well. The darkness is your ally.
|
||||
effect: +2 DEX, +1 CHA, advantage on stealth checks in darkness
|
||||
|
||||
escaped_captive:
|
||||
id: escaped_captive
|
||||
name: The Escaped Captive
|
||||
description: |
|
||||
You were a prisoner at Ironpeak Pass, one of the most notorious holding facilities
|
||||
in the realm. How you ended up there, you remember all too well—whether you were
|
||||
guilty or innocent, the iron bars didn't care. Days blurred into weeks, weeks into
|
||||
months, and you felt yourself becoming just another forgotten soul.
|
||||
|
||||
But you refused to accept that fate. Through cunning, luck, or sheer desperation,
|
||||
you escaped. Now you stand on the other side of those mountain walls, breathing
|
||||
free air for the first time in what feels like forever.
|
||||
|
||||
Freedom tastes sweet, but it comes with a price. The authorities will be searching
|
||||
for you. Your face might be on wanted posters. Anyone who learns of your past might
|
||||
turn you in for a reward. You must build a new life while constantly looking over
|
||||
your shoulder.
|
||||
|
||||
Can you truly start fresh, or will your past always define you? That depends on
|
||||
the choices you make from here.
|
||||
|
||||
starting_location:
|
||||
id: ironpeak_pass
|
||||
name: Ironpeak Pass
|
||||
region: The Frost Peaks
|
||||
description: A treacherous mountain passage near the prison you escaped, where few travelers venture
|
||||
|
||||
narrative_hooks:
|
||||
- Bounty hunters or guards searching for you
|
||||
- NPCs who recognize you from your past
|
||||
- The crime you were imprisoned for (guilty or framed)
|
||||
- Fellow prisoners or guards from Ironpeak
|
||||
- Building a new identity while hiding your past
|
||||
- Redemption arc or embracing your criminal nature
|
||||
|
||||
starting_bonus:
|
||||
trait: Hardened Survivor
|
||||
description: Prison taught you to endure hardship and seize opportunities.
|
||||
effect: +2 CON, +1 STR, bonus to survival and escape checks
|
||||
34
api/app/game_logic/__init__.py
Normal file
34
api/app/game_logic/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Game logic module for Code of Conquest.
|
||||
|
||||
This module contains core game mechanics that determine outcomes
|
||||
before they are passed to AI for narration.
|
||||
"""
|
||||
|
||||
from app.game_logic.dice import (
|
||||
CheckResult,
|
||||
SkillType,
|
||||
Difficulty,
|
||||
roll_d20,
|
||||
calculate_modifier,
|
||||
skill_check,
|
||||
get_stat_for_skill,
|
||||
perception_check,
|
||||
stealth_check,
|
||||
persuasion_check,
|
||||
lockpicking_check,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CheckResult",
|
||||
"SkillType",
|
||||
"Difficulty",
|
||||
"roll_d20",
|
||||
"calculate_modifier",
|
||||
"skill_check",
|
||||
"get_stat_for_skill",
|
||||
"perception_check",
|
||||
"stealth_check",
|
||||
"persuasion_check",
|
||||
"lockpicking_check",
|
||||
]
|
||||
247
api/app/game_logic/dice.py
Normal file
247
api/app/game_logic/dice.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Dice mechanics module for Code of Conquest.
|
||||
|
||||
This module provides core dice rolling functionality using a D20 + modifier vs DC system.
|
||||
All game chance mechanics (searches, skill checks, etc.) use these functions to determine
|
||||
outcomes before passing results to AI for narration.
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Difficulty(Enum):
|
||||
"""Standard difficulty classes for skill checks."""
|
||||
TRIVIAL = 5
|
||||
EASY = 10
|
||||
MEDIUM = 15
|
||||
HARD = 20
|
||||
VERY_HARD = 25
|
||||
NEARLY_IMPOSSIBLE = 30
|
||||
|
||||
|
||||
class SkillType(Enum):
|
||||
"""
|
||||
Skill types and their associated base stats.
|
||||
|
||||
Each skill maps to a core stat for modifier calculation.
|
||||
"""
|
||||
# Wisdom-based
|
||||
PERCEPTION = "wisdom"
|
||||
INSIGHT = "wisdom"
|
||||
SURVIVAL = "wisdom"
|
||||
MEDICINE = "wisdom"
|
||||
|
||||
# Dexterity-based
|
||||
STEALTH = "dexterity"
|
||||
ACROBATICS = "dexterity"
|
||||
SLEIGHT_OF_HAND = "dexterity"
|
||||
LOCKPICKING = "dexterity"
|
||||
|
||||
# Charisma-based
|
||||
PERSUASION = "charisma"
|
||||
DECEPTION = "charisma"
|
||||
INTIMIDATION = "charisma"
|
||||
PERFORMANCE = "charisma"
|
||||
|
||||
# Strength-based
|
||||
ATHLETICS = "strength"
|
||||
|
||||
# Intelligence-based
|
||||
ARCANA = "intelligence"
|
||||
HISTORY = "intelligence"
|
||||
INVESTIGATION = "intelligence"
|
||||
NATURE = "intelligence"
|
||||
RELIGION = "intelligence"
|
||||
|
||||
# Constitution-based
|
||||
ENDURANCE = "constitution"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""
|
||||
Result of a dice check.
|
||||
|
||||
Contains all information needed for UI display (dice roll animation)
|
||||
and game logic (success/failure determination).
|
||||
|
||||
Attributes:
|
||||
roll: The natural d20 roll (1-20)
|
||||
modifier: Total modifier from stats
|
||||
total: roll + modifier
|
||||
dc: Difficulty class that was checked against
|
||||
success: Whether the check succeeded
|
||||
margin: How much the check succeeded or failed by (total - dc)
|
||||
skill_type: The skill used for this check (if applicable)
|
||||
"""
|
||||
roll: int
|
||||
modifier: int
|
||||
total: int
|
||||
dc: int
|
||||
success: bool
|
||||
margin: int
|
||||
skill_type: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_critical_success(self) -> bool:
|
||||
"""Natural 20 - only relevant for combat."""
|
||||
return self.roll == 20
|
||||
|
||||
@property
|
||||
def is_critical_failure(self) -> bool:
|
||||
"""Natural 1 - only relevant for combat."""
|
||||
return self.roll == 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"roll": self.roll,
|
||||
"modifier": self.modifier,
|
||||
"total": self.total,
|
||||
"dc": self.dc,
|
||||
"success": self.success,
|
||||
"margin": self.margin,
|
||||
"skill_type": self.skill_type,
|
||||
}
|
||||
|
||||
|
||||
def roll_d20() -> int:
|
||||
"""
|
||||
Roll a standard 20-sided die.
|
||||
|
||||
Returns:
|
||||
Integer from 1 to 20 (inclusive)
|
||||
"""
|
||||
return random.randint(1, 20)
|
||||
|
||||
|
||||
def calculate_modifier(stat_value: int) -> int:
|
||||
"""
|
||||
Calculate the D&D-style modifier from a stat value.
|
||||
|
||||
Formula: (stat - 10) // 2
|
||||
|
||||
Examples:
|
||||
- Stat 10 = +0 modifier
|
||||
- Stat 14 = +2 modifier
|
||||
- Stat 18 = +4 modifier
|
||||
- Stat 8 = -1 modifier
|
||||
|
||||
Args:
|
||||
stat_value: The raw stat value (typically 1-20)
|
||||
|
||||
Returns:
|
||||
The modifier value (can be negative)
|
||||
"""
|
||||
return (stat_value - 10) // 2
|
||||
|
||||
|
||||
def skill_check(
|
||||
stat_value: int,
|
||||
dc: int,
|
||||
skill_type: Optional[SkillType] = None,
|
||||
bonus: int = 0
|
||||
) -> CheckResult:
|
||||
"""
|
||||
Perform a skill check: d20 + modifier vs DC.
|
||||
|
||||
Args:
|
||||
stat_value: The relevant stat value (e.g., character's wisdom for perception)
|
||||
dc: Difficulty class to beat
|
||||
skill_type: Optional skill type for logging/display
|
||||
bonus: Additional bonus (e.g., from equipment or proficiency)
|
||||
|
||||
Returns:
|
||||
CheckResult with full details of the roll
|
||||
"""
|
||||
roll = roll_d20()
|
||||
modifier = calculate_modifier(stat_value) + bonus
|
||||
total = roll + modifier
|
||||
success = total >= dc
|
||||
margin = total - dc
|
||||
|
||||
return CheckResult(
|
||||
roll=roll,
|
||||
modifier=modifier,
|
||||
total=total,
|
||||
dc=dc,
|
||||
success=success,
|
||||
margin=margin,
|
||||
skill_type=skill_type.name if skill_type else None
|
||||
)
|
||||
|
||||
|
||||
def get_stat_for_skill(skill_type: SkillType) -> str:
|
||||
"""
|
||||
Get the base stat name for a skill type.
|
||||
|
||||
Args:
|
||||
skill_type: The skill to look up
|
||||
|
||||
Returns:
|
||||
The stat name (e.g., "wisdom", "dexterity")
|
||||
"""
|
||||
return skill_type.value
|
||||
|
||||
|
||||
def perception_check(wisdom: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for perception checks (searching, spotting).
|
||||
|
||||
Args:
|
||||
wisdom: Character's wisdom stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(wisdom, dc, SkillType.PERCEPTION, bonus)
|
||||
|
||||
|
||||
def stealth_check(dexterity: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for stealth checks (sneaking, hiding).
|
||||
|
||||
Args:
|
||||
dexterity: Character's dexterity stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(dexterity, dc, SkillType.STEALTH, bonus)
|
||||
|
||||
|
||||
def persuasion_check(charisma: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for persuasion checks (convincing, negotiating).
|
||||
|
||||
Args:
|
||||
charisma: Character's charisma stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(charisma, dc, SkillType.PERSUASION, bonus)
|
||||
|
||||
|
||||
def lockpicking_check(dexterity: int, dc: int, bonus: int = 0) -> CheckResult:
|
||||
"""
|
||||
Convenience function for lockpicking checks.
|
||||
|
||||
Args:
|
||||
dexterity: Character's dexterity stat
|
||||
dc: Difficulty class
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
CheckResult
|
||||
"""
|
||||
return skill_check(dexterity, dc, SkillType.LOCKPICKING, bonus)
|
||||
87
api/app/models/__init__.py
Normal file
87
api/app/models/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Data models for Code of Conquest.
|
||||
|
||||
This package contains all dataclass models used throughout the application.
|
||||
"""
|
||||
|
||||
# Enums
|
||||
from app.models.enums import (
|
||||
EffectType,
|
||||
DamageType,
|
||||
ItemType,
|
||||
StatType,
|
||||
AbilityType,
|
||||
CombatStatus,
|
||||
SessionStatus,
|
||||
ListingStatus,
|
||||
ListingType,
|
||||
)
|
||||
|
||||
# Core models
|
||||
from app.models.stats import Stats
|
||||
from app.models.effects import Effect
|
||||
from app.models.abilities import Ability, AbilityLoader
|
||||
from app.models.items import Item
|
||||
|
||||
# Progression
|
||||
from app.models.skills import SkillNode, SkillTree, PlayerClass
|
||||
|
||||
# Character
|
||||
from app.models.character import Character
|
||||
|
||||
# Combat
|
||||
from app.models.combat import Combatant, CombatEncounter
|
||||
|
||||
# Session
|
||||
from app.models.session import (
|
||||
SessionConfig,
|
||||
GameState,
|
||||
ConversationEntry,
|
||||
GameSession,
|
||||
)
|
||||
|
||||
# Marketplace
|
||||
from app.models.marketplace import (
|
||||
Bid,
|
||||
MarketplaceListing,
|
||||
Transaction,
|
||||
ShopItem,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"EffectType",
|
||||
"DamageType",
|
||||
"ItemType",
|
||||
"StatType",
|
||||
"AbilityType",
|
||||
"CombatStatus",
|
||||
"SessionStatus",
|
||||
"ListingStatus",
|
||||
"ListingType",
|
||||
# Core models
|
||||
"Stats",
|
||||
"Effect",
|
||||
"Ability",
|
||||
"AbilityLoader",
|
||||
"Item",
|
||||
# Progression
|
||||
"SkillNode",
|
||||
"SkillTree",
|
||||
"PlayerClass",
|
||||
# Character
|
||||
"Character",
|
||||
# Combat
|
||||
"Combatant",
|
||||
"CombatEncounter",
|
||||
# Session
|
||||
"SessionConfig",
|
||||
"GameState",
|
||||
"ConversationEntry",
|
||||
"GameSession",
|
||||
# Marketplace
|
||||
"Bid",
|
||||
"MarketplaceListing",
|
||||
"Transaction",
|
||||
"ShopItem",
|
||||
]
|
||||
237
api/app/models/abilities.py
Normal file
237
api/app/models/abilities.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Ability system for combat actions and spells.
|
||||
|
||||
This module defines abilities (attacks, spells, skills) that can be used in combat.
|
||||
Abilities are loaded from YAML configuration files for data-driven design.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
import yaml
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.models.enums import AbilityType, DamageType, EffectType, StatType
|
||||
from app.models.effects import Effect
|
||||
from app.models.stats import Stats
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ability:
|
||||
"""
|
||||
Represents an action that can be taken in combat.
|
||||
|
||||
Abilities can deal damage, apply effects, heal, or perform other actions.
|
||||
They are loaded from YAML files for easy game design iteration.
|
||||
|
||||
Attributes:
|
||||
ability_id: Unique identifier
|
||||
name: Display name
|
||||
description: What the ability does
|
||||
ability_type: Category (attack, spell, skill, etc.)
|
||||
base_power: Base damage or healing value
|
||||
damage_type: Type of damage dealt (physical, fire, etc.)
|
||||
scaling_stat: Which stat scales this ability's power (if any)
|
||||
scaling_factor: Multiplier for scaling stat (default 0.5)
|
||||
mana_cost: MP required to use this ability
|
||||
cooldown: Turns before ability can be used again
|
||||
effects_applied: List of effects applied to target on hit
|
||||
is_aoe: Whether this affects multiple targets
|
||||
target_count: Number of targets if AoE (0 = all)
|
||||
"""
|
||||
|
||||
ability_id: str
|
||||
name: str
|
||||
description: str
|
||||
ability_type: AbilityType
|
||||
base_power: int = 0
|
||||
damage_type: Optional[DamageType] = None
|
||||
scaling_stat: Optional[StatType] = None
|
||||
scaling_factor: float = 0.5
|
||||
mana_cost: int = 0
|
||||
cooldown: int = 0
|
||||
effects_applied: List[Effect] = field(default_factory=list)
|
||||
is_aoe: bool = False
|
||||
target_count: int = 1
|
||||
|
||||
def calculate_power(self, caster_stats: Stats) -> int:
|
||||
"""
|
||||
Calculate final power based on caster's stats.
|
||||
|
||||
Formula: base_power + (scaling_stat × scaling_factor)
|
||||
Minimum power is always 1.
|
||||
|
||||
Args:
|
||||
caster_stats: The caster's effective stats
|
||||
|
||||
Returns:
|
||||
Final power value for damage or healing
|
||||
"""
|
||||
power = self.base_power
|
||||
|
||||
if self.scaling_stat:
|
||||
stat_value = getattr(caster_stats, self.scaling_stat.value)
|
||||
power += int(stat_value * self.scaling_factor)
|
||||
|
||||
return max(1, power)
|
||||
|
||||
def get_effects_to_apply(self) -> List[Effect]:
|
||||
"""
|
||||
Get a copy of effects that should be applied to target(s).
|
||||
|
||||
Creates new Effect instances to avoid sharing references.
|
||||
|
||||
Returns:
|
||||
List of Effect instances to apply
|
||||
"""
|
||||
return [
|
||||
Effect(
|
||||
effect_id=f"{self.ability_id}_{effect.name}_{id(effect)}",
|
||||
name=effect.name,
|
||||
effect_type=effect.effect_type,
|
||||
duration=effect.duration,
|
||||
power=effect.power,
|
||||
stat_affected=effect.stat_affected,
|
||||
stacks=effect.stacks,
|
||||
max_stacks=effect.max_stacks,
|
||||
source=self.ability_id,
|
||||
)
|
||||
for effect in self.effects_applied
|
||||
]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize ability to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all ability data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["ability_type"] = self.ability_type.value
|
||||
if self.damage_type:
|
||||
data["damage_type"] = self.damage_type.value
|
||||
if self.scaling_stat:
|
||||
data["scaling_stat"] = self.scaling_stat.value
|
||||
data["effects_applied"] = [effect.to_dict() for effect in self.effects_applied]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Ability':
|
||||
"""
|
||||
Deserialize ability from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing ability data
|
||||
|
||||
Returns:
|
||||
Ability instance
|
||||
"""
|
||||
# Convert string values back to enums
|
||||
ability_type = AbilityType(data["ability_type"])
|
||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||
scaling_stat = StatType(data["scaling_stat"]) if data.get("scaling_stat") else None
|
||||
|
||||
# Deserialize effects
|
||||
effects = []
|
||||
if "effects_applied" in data and data["effects_applied"]:
|
||||
effects = [Effect.from_dict(e) for e in data["effects_applied"]]
|
||||
|
||||
return cls(
|
||||
ability_id=data["ability_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
ability_type=ability_type,
|
||||
base_power=data.get("base_power", 0),
|
||||
damage_type=damage_type,
|
||||
scaling_stat=scaling_stat,
|
||||
scaling_factor=data.get("scaling_factor", 0.5),
|
||||
mana_cost=data.get("mana_cost", 0),
|
||||
cooldown=data.get("cooldown", 0),
|
||||
effects_applied=effects,
|
||||
is_aoe=data.get("is_aoe", False),
|
||||
target_count=data.get("target_count", 1),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the ability."""
|
||||
return (
|
||||
f"Ability({self.name}, {self.ability_type.value}, "
|
||||
f"power={self.base_power}, cost={self.mana_cost}MP, "
|
||||
f"cooldown={self.cooldown}t)"
|
||||
)
|
||||
|
||||
|
||||
class AbilityLoader:
|
||||
"""
|
||||
Loads abilities from YAML configuration files.
|
||||
|
||||
This allows game designers to define abilities without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the ability loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing ability YAML files
|
||||
Defaults to /app/data/abilities/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/abilities relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "abilities")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._ability_cache: Dict[str, Ability] = {}
|
||||
|
||||
def load_ability(self, ability_id: str) -> Optional[Ability]:
|
||||
"""
|
||||
Load a single ability by ID.
|
||||
|
||||
Args:
|
||||
ability_id: Unique ability identifier
|
||||
|
||||
Returns:
|
||||
Ability instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if ability_id in self._ability_cache:
|
||||
return self._ability_cache[ability_id]
|
||||
|
||||
# Load from YAML file
|
||||
yaml_file = self.data_dir / f"{ability_id}.yaml"
|
||||
if not yaml_file.exists():
|
||||
return None
|
||||
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
ability = Ability.from_dict(data)
|
||||
self._ability_cache[ability_id] = ability
|
||||
return ability
|
||||
|
||||
def load_all_abilities(self) -> Dict[str, Ability]:
|
||||
"""
|
||||
Load all abilities from the data directory.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping ability_id to Ability instance
|
||||
"""
|
||||
if not self.data_dir.exists():
|
||||
return {}
|
||||
|
||||
abilities = {}
|
||||
for yaml_file in self.data_dir.glob("*.yaml"):
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
ability = Ability.from_dict(data)
|
||||
abilities[ability.ability_id] = ability
|
||||
self._ability_cache[ability.ability_id] = ability
|
||||
|
||||
return abilities
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the ability cache, forcing reload on next access."""
|
||||
self._ability_cache.clear()
|
||||
296
api/app/models/action_prompt.py
Normal file
296
api/app/models/action_prompt.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Action Prompt Model
|
||||
|
||||
This module defines the ActionPrompt dataclass for button-based story actions.
|
||||
Each action prompt represents a predefined action that players can take during
|
||||
story progression, with tier-based availability and context filtering.
|
||||
|
||||
Usage:
|
||||
from app.models.action_prompt import ActionPrompt, ActionCategory, LocationType
|
||||
|
||||
action = ActionPrompt(
|
||||
prompt_id="ask_locals",
|
||||
category=ActionCategory.ASK_QUESTION,
|
||||
display_text="Ask locals for information",
|
||||
description="Talk to NPCs to learn about quests and rumors",
|
||||
tier_required=UserTier.FREE,
|
||||
context_filter=[LocationType.TOWN, LocationType.TAVERN],
|
||||
dm_prompt_template="The player asks locals about {{ topic }}..."
|
||||
)
|
||||
|
||||
if action.is_available(UserTier.FREE, LocationType.TOWN):
|
||||
# Show action button to player
|
||||
pass
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Any, Dict
|
||||
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckRequirement:
|
||||
"""
|
||||
Defines the dice check required for an action.
|
||||
|
||||
Used to determine outcomes before AI narration.
|
||||
"""
|
||||
check_type: str # "search" or "skill"
|
||||
skill: Optional[str] = None # For skill checks: perception, persuasion, etc.
|
||||
difficulty: str = "medium" # trivial, easy, medium, hard, very_hard
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"check_type": self.check_type,
|
||||
"skill": self.skill,
|
||||
"difficulty": self.difficulty,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CheckRequirement":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
check_type=data.get("check_type", "skill"),
|
||||
skill=data.get("skill"),
|
||||
difficulty=data.get("difficulty", "medium"),
|
||||
)
|
||||
|
||||
|
||||
class ActionCategory(str, Enum):
|
||||
"""Categories of story actions."""
|
||||
ASK_QUESTION = "ask_question" # Gather information from NPCs
|
||||
TRAVEL = "travel" # Move to a new location
|
||||
GATHER_INFO = "gather_info" # Search or investigate
|
||||
REST = "rest" # Rest and recover
|
||||
INTERACT = "interact" # Interact with objects/environment
|
||||
EXPLORE = "explore" # Explore the area
|
||||
SPECIAL = "special" # Special tier-specific actions
|
||||
|
||||
|
||||
class LocationType(str, Enum):
|
||||
"""Types of locations in the game world."""
|
||||
TOWN = "town" # Populated settlements
|
||||
TAVERN = "tavern" # Taverns and inns
|
||||
WILDERNESS = "wilderness" # Outdoor areas, forests, fields
|
||||
DUNGEON = "dungeon" # Dungeons and caves
|
||||
SAFE_AREA = "safe_area" # Protected zones, temples
|
||||
LIBRARY = "library" # Libraries and archives
|
||||
ANY = "any" # Available in all locations
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionPrompt:
|
||||
"""
|
||||
Represents a predefined story action that players can select.
|
||||
|
||||
Action prompts are displayed as buttons in the story UI. Each action
|
||||
has tier requirements and context filters to determine availability.
|
||||
|
||||
Attributes:
|
||||
prompt_id: Unique identifier for the action
|
||||
category: Category of action (ASK_QUESTION, TRAVEL, etc.)
|
||||
display_text: Text shown on the action button
|
||||
description: Tooltip/help text explaining the action
|
||||
tier_required: Minimum subscription tier required
|
||||
context_filter: List of location types where action is available
|
||||
dm_prompt_template: Jinja2 template for generating AI prompt
|
||||
icon: Optional icon name for the button
|
||||
cooldown_turns: Optional cooldown in turns before action can be used again
|
||||
"""
|
||||
|
||||
prompt_id: str
|
||||
category: ActionCategory
|
||||
display_text: str
|
||||
description: str
|
||||
tier_required: UserTier
|
||||
context_filter: List[LocationType]
|
||||
dm_prompt_template: str
|
||||
icon: Optional[str] = None
|
||||
cooldown_turns: int = 0
|
||||
requires_check: Optional[CheckRequirement] = None
|
||||
|
||||
def is_available(self, user_tier: UserTier, location_type: LocationType) -> bool:
|
||||
"""
|
||||
Check if this action is available for a user at a location.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
True if the action is available, False otherwise
|
||||
"""
|
||||
# Check tier requirement
|
||||
if not self._tier_meets_requirement(user_tier):
|
||||
return False
|
||||
|
||||
# Check location filter
|
||||
if not self._location_matches_filter(location_type):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _tier_meets_requirement(self, user_tier: UserTier) -> bool:
|
||||
"""
|
||||
Check if user tier meets the minimum requirement.
|
||||
|
||||
Tier hierarchy: FREE < BASIC < PREMIUM < ELITE
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
True if tier requirement is met
|
||||
"""
|
||||
tier_order = {
|
||||
UserTier.FREE: 0,
|
||||
UserTier.BASIC: 1,
|
||||
UserTier.PREMIUM: 2,
|
||||
UserTier.ELITE: 3,
|
||||
}
|
||||
|
||||
user_level = tier_order.get(user_tier, 0)
|
||||
required_level = tier_order.get(self.tier_required, 0)
|
||||
|
||||
return user_level >= required_level
|
||||
|
||||
def _location_matches_filter(self, location_type: LocationType) -> bool:
|
||||
"""
|
||||
Check if location matches the context filter.
|
||||
|
||||
Args:
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
True if location matches filter
|
||||
"""
|
||||
# ANY location type matches everything
|
||||
if LocationType.ANY in self.context_filter:
|
||||
return True
|
||||
|
||||
# Check if location is in the filter list
|
||||
return location_type in self.context_filter
|
||||
|
||||
def is_locked(self, user_tier: UserTier) -> bool:
|
||||
"""
|
||||
Check if this action is locked due to tier restriction.
|
||||
|
||||
Used to show locked actions with upgrade prompts.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
True if the action is locked (tier too low)
|
||||
"""
|
||||
return not self._tier_meets_requirement(user_tier)
|
||||
|
||||
def get_lock_reason(self, user_tier: UserTier) -> Optional[str]:
|
||||
"""
|
||||
Get the reason why an action is locked.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Lock reason message, or None if not locked
|
||||
"""
|
||||
if not self._tier_meets_requirement(user_tier):
|
||||
tier_names = {
|
||||
UserTier.FREE: "Free",
|
||||
UserTier.BASIC: "Basic",
|
||||
UserTier.PREMIUM: "Premium",
|
||||
UserTier.ELITE: "Elite",
|
||||
}
|
||||
required_name = tier_names.get(self.tier_required, "Unknown")
|
||||
return f"Requires {required_name} tier or higher"
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert to dictionary for JSON serialization.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the action prompt
|
||||
"""
|
||||
result = {
|
||||
"prompt_id": self.prompt_id,
|
||||
"category": self.category.value,
|
||||
"display_text": self.display_text,
|
||||
"description": self.description,
|
||||
"tier_required": self.tier_required.value,
|
||||
"context_filter": [loc.value for loc in self.context_filter],
|
||||
"dm_prompt_template": self.dm_prompt_template,
|
||||
"icon": self.icon,
|
||||
"cooldown_turns": self.cooldown_turns,
|
||||
}
|
||||
if self.requires_check:
|
||||
result["requires_check"] = self.requires_check.to_dict()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ActionPrompt":
|
||||
"""
|
||||
Create an ActionPrompt from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing action prompt data
|
||||
|
||||
Returns:
|
||||
ActionPrompt instance
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing or invalid
|
||||
"""
|
||||
# Parse category enum
|
||||
category_str = data.get("category", "")
|
||||
try:
|
||||
category = ActionCategory(category_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid action category: {category_str}")
|
||||
|
||||
# Parse tier enum
|
||||
tier_str = data.get("tier_required", "free")
|
||||
try:
|
||||
tier_required = UserTier(tier_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid user tier: {tier_str}")
|
||||
|
||||
# Parse location types
|
||||
context_filter_raw = data.get("context_filter", ["any"])
|
||||
context_filter = []
|
||||
for loc_str in context_filter_raw:
|
||||
try:
|
||||
context_filter.append(LocationType(loc_str.lower()))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid location type: {loc_str}")
|
||||
|
||||
# Parse requires_check if present
|
||||
requires_check = None
|
||||
if "requires_check" in data and data["requires_check"]:
|
||||
requires_check = CheckRequirement.from_dict(data["requires_check"])
|
||||
|
||||
return cls(
|
||||
prompt_id=data.get("prompt_id", ""),
|
||||
category=category,
|
||||
display_text=data.get("display_text", ""),
|
||||
description=data.get("description", ""),
|
||||
tier_required=tier_required,
|
||||
context_filter=context_filter,
|
||||
dm_prompt_template=data.get("dm_prompt_template", ""),
|
||||
icon=data.get("icon"),
|
||||
cooldown_turns=data.get("cooldown_turns", 0),
|
||||
requires_check=requires_check,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return (
|
||||
f"ActionPrompt(prompt_id='{self.prompt_id}', "
|
||||
f"category={self.category.value}, "
|
||||
f"tier={self.tier_required.value})"
|
||||
)
|
||||
211
api/app/models/ai_usage.py
Normal file
211
api/app/models/ai_usage.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
AI Usage data model for tracking AI generation costs and usage.
|
||||
|
||||
This module defines the AIUsageLog dataclass which represents a single AI usage
|
||||
event for tracking costs, tokens used, and generating usage analytics.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, date
|
||||
from typing import Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
"""Types of AI tasks that can be tracked."""
|
||||
STORY_PROGRESSION = "story_progression"
|
||||
COMBAT_NARRATION = "combat_narration"
|
||||
QUEST_SELECTION = "quest_selection"
|
||||
NPC_DIALOGUE = "npc_dialogue"
|
||||
GENERAL = "general"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIUsageLog:
|
||||
"""
|
||||
Represents a single AI usage event for cost and usage tracking.
|
||||
|
||||
This dataclass captures all relevant information about an AI API call
|
||||
including the user, model used, tokens consumed, and estimated cost.
|
||||
Used for:
|
||||
- Cost monitoring and budgeting
|
||||
- Usage analytics per user/tier
|
||||
- Rate limiting enforcement
|
||||
- Billing and invoicing (future)
|
||||
|
||||
Attributes:
|
||||
log_id: Unique identifier for this usage log entry
|
||||
user_id: User who made the request
|
||||
timestamp: When the request was made
|
||||
model: Model identifier (e.g., "meta/meta-llama-3-8b-instruct")
|
||||
tokens_input: Number of input tokens (prompt)
|
||||
tokens_output: Number of output tokens (response)
|
||||
tokens_total: Total tokens used (input + output)
|
||||
estimated_cost: Estimated cost in USD
|
||||
task_type: Type of task (story, combat, quest, npc)
|
||||
session_id: Optional game session ID for context
|
||||
character_id: Optional character ID for context
|
||||
request_duration_ms: How long the request took in milliseconds
|
||||
success: Whether the request completed successfully
|
||||
error_message: Error message if the request failed
|
||||
"""
|
||||
|
||||
log_id: str
|
||||
user_id: str
|
||||
timestamp: datetime
|
||||
model: str
|
||||
tokens_input: int
|
||||
tokens_output: int
|
||||
tokens_total: int
|
||||
estimated_cost: float
|
||||
task_type: TaskType
|
||||
session_id: Optional[str] = None
|
||||
character_id: Optional[str] = None
|
||||
request_duration_ms: int = 0
|
||||
success: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert usage log to dictionary for storage.
|
||||
|
||||
Returns:
|
||||
Dictionary representation suitable for Appwrite storage
|
||||
"""
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"timestamp": self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
|
||||
"model": self.model,
|
||||
"tokens_input": self.tokens_input,
|
||||
"tokens_output": self.tokens_output,
|
||||
"tokens_total": self.tokens_total,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"task_type": self.task_type.value if isinstance(self.task_type, TaskType) else self.task_type,
|
||||
"session_id": self.session_id,
|
||||
"character_id": self.character_id,
|
||||
"request_duration_ms": self.request_duration_ms,
|
||||
"success": self.success,
|
||||
"error_message": self.error_message,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AIUsageLog":
|
||||
"""
|
||||
Create AIUsageLog from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary with usage log data
|
||||
|
||||
Returns:
|
||||
AIUsageLog instance
|
||||
"""
|
||||
# Parse timestamp
|
||||
timestamp = data.get("timestamp")
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
||||
elif timestamp is None:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
# Parse task type
|
||||
task_type = data.get("task_type", "general")
|
||||
if isinstance(task_type, str):
|
||||
try:
|
||||
task_type = TaskType(task_type)
|
||||
except ValueError:
|
||||
task_type = TaskType.GENERAL
|
||||
|
||||
return cls(
|
||||
log_id=data.get("log_id", ""),
|
||||
user_id=data.get("user_id", ""),
|
||||
timestamp=timestamp,
|
||||
model=data.get("model", ""),
|
||||
tokens_input=data.get("tokens_input", 0),
|
||||
tokens_output=data.get("tokens_output", 0),
|
||||
tokens_total=data.get("tokens_total", 0),
|
||||
estimated_cost=data.get("estimated_cost", 0.0),
|
||||
task_type=task_type,
|
||||
session_id=data.get("session_id"),
|
||||
character_id=data.get("character_id"),
|
||||
request_duration_ms=data.get("request_duration_ms", 0),
|
||||
success=data.get("success", True),
|
||||
error_message=data.get("error_message"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyUsageSummary:
|
||||
"""
|
||||
Summary of AI usage for a specific day.
|
||||
|
||||
Used for reporting and rate limiting checks.
|
||||
|
||||
Attributes:
|
||||
date: The date of this summary
|
||||
user_id: User ID
|
||||
total_requests: Number of AI requests made
|
||||
total_tokens: Total tokens consumed
|
||||
total_input_tokens: Total input tokens
|
||||
total_output_tokens: Total output tokens
|
||||
estimated_cost: Total estimated cost in USD
|
||||
requests_by_task: Breakdown of requests by task type
|
||||
"""
|
||||
|
||||
date: date
|
||||
user_id: str
|
||||
total_requests: int
|
||||
total_tokens: int
|
||||
total_input_tokens: int
|
||||
total_output_tokens: int
|
||||
estimated_cost: float
|
||||
requests_by_task: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert summary to dictionary."""
|
||||
return {
|
||||
"date": self.date.isoformat() if isinstance(self.date, date) else self.date,
|
||||
"user_id": self.user_id,
|
||||
"total_requests": self.total_requests,
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_input_tokens": self.total_input_tokens,
|
||||
"total_output_tokens": self.total_output_tokens,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"requests_by_task": self.requests_by_task,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonthlyUsageSummary:
|
||||
"""
|
||||
Summary of AI usage for a specific month.
|
||||
|
||||
Used for billing and cost projections.
|
||||
|
||||
Attributes:
|
||||
year: Year
|
||||
month: Month (1-12)
|
||||
user_id: User ID
|
||||
total_requests: Number of AI requests made
|
||||
total_tokens: Total tokens consumed
|
||||
estimated_cost: Total estimated cost in USD
|
||||
daily_breakdown: List of daily summaries
|
||||
"""
|
||||
|
||||
year: int
|
||||
month: int
|
||||
user_id: str
|
||||
total_requests: int
|
||||
total_tokens: int
|
||||
estimated_cost: float
|
||||
daily_breakdown: list = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert summary to dictionary."""
|
||||
return {
|
||||
"year": self.year,
|
||||
"month": self.month,
|
||||
"user_id": self.user_id,
|
||||
"total_requests": self.total_requests,
|
||||
"total_tokens": self.total_tokens,
|
||||
"estimated_cost": self.estimated_cost,
|
||||
"daily_breakdown": self.daily_breakdown,
|
||||
}
|
||||
452
api/app/models/character.py
Normal file
452
api/app/models/character.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Character data model - the core entity for player characters.
|
||||
|
||||
This module defines the Character dataclass which represents a player's character
|
||||
with all their stats, inventory, progression, and the critical get_effective_stats()
|
||||
method that calculates final stats from all sources.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.items import Item
|
||||
from app.models.skills import PlayerClass, SkillNode
|
||||
from app.models.effects import Effect
|
||||
from app.models.enums import EffectType, StatType
|
||||
from app.models.origins import Origin
|
||||
|
||||
|
||||
@dataclass
|
||||
class Character:
|
||||
"""
|
||||
Represents a player's character.
|
||||
|
||||
This is the central data model that ties together all character-related data:
|
||||
stats, class, inventory, progression, and quests.
|
||||
|
||||
The critical method is get_effective_stats() which calculates the final stats
|
||||
by combining base stats + equipment bonuses + skill bonuses + active effects.
|
||||
|
||||
Attributes:
|
||||
character_id: Unique identifier
|
||||
user_id: Owner's user ID (from Appwrite auth)
|
||||
name: Character name
|
||||
player_class: Character's class (determines base stats and skill trees)
|
||||
origin: Character's backstory origin (saved for AI DM narrative hooks)
|
||||
level: Current level
|
||||
experience: Current XP points
|
||||
base_stats: Base stats (from class + level-ups)
|
||||
unlocked_skills: List of skill_ids that have been unlocked
|
||||
inventory: All items the character owns
|
||||
equipped: Currently equipped items by slot
|
||||
Slots: "weapon", "armor", "helmet", "boots", "accessory", etc.
|
||||
gold: Currency amount
|
||||
active_quests: List of quest IDs currently in progress
|
||||
discovered_locations: List of location IDs the character has visited
|
||||
current_location: Current location ID (tracks character position)
|
||||
"""
|
||||
|
||||
character_id: str
|
||||
user_id: str
|
||||
name: str
|
||||
player_class: PlayerClass
|
||||
origin: Origin
|
||||
level: int = 1
|
||||
experience: int = 0
|
||||
|
||||
# Stats and progression
|
||||
base_stats: Stats = field(default_factory=Stats)
|
||||
unlocked_skills: List[str] = field(default_factory=list)
|
||||
|
||||
# Inventory and equipment
|
||||
inventory: List[Item] = field(default_factory=list)
|
||||
equipped: Dict[str, Item] = field(default_factory=dict)
|
||||
gold: int = 0
|
||||
|
||||
# Quests and exploration
|
||||
active_quests: 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
|
||||
|
||||
# NPC interaction tracking (persists across sessions)
|
||||
# Each entry: {npc_id: {interaction_count, relationship_level, dialogue_history, ...}}
|
||||
# dialogue_history: List[{player_line: str, npc_response: str}]
|
||||
npc_interactions: Dict[str, Dict] = field(default_factory=dict)
|
||||
|
||||
def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats:
|
||||
"""
|
||||
Calculate final effective stats from all sources.
|
||||
|
||||
This is the CRITICAL METHOD that combines:
|
||||
1. Base stats (from character)
|
||||
2. Equipment bonuses (from equipped items)
|
||||
3. Skill tree bonuses (from unlocked skills)
|
||||
4. Active effect modifiers (buffs/debuffs)
|
||||
|
||||
Args:
|
||||
active_effects: Currently active effects on this character (from combat)
|
||||
|
||||
Returns:
|
||||
Stats instance with all modifiers applied
|
||||
"""
|
||||
# Start with a copy of base stats
|
||||
effective = self.base_stats.copy()
|
||||
|
||||
# Apply equipment bonuses
|
||||
for item in self.equipped.values():
|
||||
for stat_name, bonus in item.stat_bonuses.items():
|
||||
if hasattr(effective, stat_name):
|
||||
current_value = getattr(effective, stat_name)
|
||||
setattr(effective, stat_name, current_value + bonus)
|
||||
|
||||
# Apply skill tree bonuses
|
||||
skill_bonuses = self._get_skill_bonuses()
|
||||
for stat_name, bonus in skill_bonuses.items():
|
||||
if hasattr(effective, stat_name):
|
||||
current_value = getattr(effective, stat_name)
|
||||
setattr(effective, stat_name, current_value + bonus)
|
||||
|
||||
# Apply active effect modifiers (buffs/debuffs)
|
||||
if active_effects:
|
||||
for effect in active_effects:
|
||||
if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
if effect.stat_affected:
|
||||
stat_name = effect.stat_affected.value
|
||||
if hasattr(effective, stat_name):
|
||||
current_value = getattr(effective, stat_name)
|
||||
modifier = effect.power * effect.stacks
|
||||
|
||||
if effect.effect_type == EffectType.BUFF:
|
||||
setattr(effective, stat_name, current_value + modifier)
|
||||
else: # DEBUFF
|
||||
# Stats can't go below 1
|
||||
setattr(effective, stat_name, max(1, current_value - modifier))
|
||||
|
||||
return effective
|
||||
|
||||
def _get_skill_bonuses(self) -> Dict[str, int]:
|
||||
"""
|
||||
Calculate total stat bonuses from unlocked skills.
|
||||
|
||||
Returns:
|
||||
Dictionary of stat bonuses from skill tree
|
||||
"""
|
||||
bonuses: Dict[str, int] = {}
|
||||
|
||||
# Get all skill nodes from all trees
|
||||
all_skills = self.player_class.get_all_skills()
|
||||
|
||||
# Sum up bonuses from unlocked skills
|
||||
for skill in all_skills:
|
||||
if skill.skill_id in self.unlocked_skills:
|
||||
skill_bonuses = skill.get_stat_bonuses()
|
||||
for stat_name, bonus in skill_bonuses.items():
|
||||
bonuses[stat_name] = bonuses.get(stat_name, 0) + bonus
|
||||
|
||||
return bonuses
|
||||
|
||||
def get_unlocked_abilities(self) -> List[str]:
|
||||
"""
|
||||
Get all ability IDs unlocked by this character's skills.
|
||||
|
||||
Returns:
|
||||
List of ability_ids from skill tree + class starting abilities
|
||||
"""
|
||||
abilities = list(self.player_class.starting_abilities)
|
||||
|
||||
# Get all skill nodes from all trees
|
||||
all_skills = self.player_class.get_all_skills()
|
||||
|
||||
# Collect abilities from unlocked skills
|
||||
for skill in all_skills:
|
||||
if skill.skill_id in self.unlocked_skills:
|
||||
abilities.extend(skill.get_unlocked_abilities())
|
||||
|
||||
return abilities
|
||||
|
||||
@property
|
||||
def class_id(self) -> str:
|
||||
"""Get class ID for template access."""
|
||||
return self.player_class.class_id
|
||||
|
||||
@property
|
||||
def origin_id(self) -> str:
|
||||
"""Get origin ID for template access."""
|
||||
return self.origin.id
|
||||
|
||||
@property
|
||||
def origin_name(self) -> str:
|
||||
"""Get origin display name for template access."""
|
||||
return self.origin.name
|
||||
|
||||
@property
|
||||
def available_skill_points(self) -> int:
|
||||
"""Calculate available skill points (1 per level minus unlocked skills)."""
|
||||
return self.level - len(self.unlocked_skills)
|
||||
|
||||
@property
|
||||
def max_hp(self) -> int:
|
||||
"""
|
||||
Calculate max HP from constitution.
|
||||
Uses the Stats.hit_points property which calculates: 10 + (constitution * 2)
|
||||
"""
|
||||
effective_stats = self.get_effective_stats()
|
||||
return effective_stats.hit_points
|
||||
|
||||
@property
|
||||
def current_hp(self) -> int:
|
||||
"""
|
||||
Get current HP.
|
||||
Outside of combat, characters are at full health.
|
||||
During combat, this would be tracked separately in the combat state.
|
||||
"""
|
||||
# For now, always return max HP (full health outside combat)
|
||||
# TODO: Track combat damage separately when implementing combat system
|
||||
return self.max_hp
|
||||
|
||||
def can_afford(self, cost: int) -> bool:
|
||||
"""Check if character has enough gold."""
|
||||
return self.gold >= cost
|
||||
|
||||
def add_gold(self, amount: int) -> None:
|
||||
"""Add gold to character's wallet."""
|
||||
self.gold += amount
|
||||
|
||||
def remove_gold(self, amount: int) -> bool:
|
||||
"""
|
||||
Remove gold from character's wallet.
|
||||
|
||||
Returns:
|
||||
True if successful, False if insufficient gold
|
||||
"""
|
||||
if not self.can_afford(amount):
|
||||
return False
|
||||
self.gold -= amount
|
||||
return True
|
||||
|
||||
def add_item(self, item: Item) -> None:
|
||||
"""Add an item to character's inventory."""
|
||||
self.inventory.append(item)
|
||||
|
||||
def remove_item(self, item_id: str) -> Optional[Item]:
|
||||
"""
|
||||
Remove an item from inventory by ID.
|
||||
|
||||
Returns:
|
||||
The removed Item or None if not found
|
||||
"""
|
||||
for i, item in enumerate(self.inventory):
|
||||
if item.item_id == item_id:
|
||||
return self.inventory.pop(i)
|
||||
return None
|
||||
|
||||
def equip_item(self, item: Item, slot: str) -> Optional[Item]:
|
||||
"""
|
||||
Equip an item to a specific slot.
|
||||
|
||||
Args:
|
||||
item: Item to equip
|
||||
slot: Equipment slot ("weapon", "armor", etc.)
|
||||
|
||||
Returns:
|
||||
Previously equipped item in that slot (or None)
|
||||
"""
|
||||
# Remove from inventory
|
||||
self.remove_item(item.item_id)
|
||||
|
||||
# Unequip current item in slot if present
|
||||
previous = self.equipped.get(slot)
|
||||
if previous:
|
||||
self.add_item(previous)
|
||||
|
||||
# Equip new item
|
||||
self.equipped[slot] = item
|
||||
return previous
|
||||
|
||||
def unequip_item(self, slot: str) -> Optional[Item]:
|
||||
"""
|
||||
Unequip an item from a slot.
|
||||
|
||||
Args:
|
||||
slot: Equipment slot to unequip from
|
||||
|
||||
Returns:
|
||||
The unequipped Item or None if slot was empty
|
||||
"""
|
||||
if slot not in self.equipped:
|
||||
return None
|
||||
|
||||
item = self.equipped.pop(slot)
|
||||
self.add_item(item)
|
||||
return item
|
||||
|
||||
def add_experience(self, xp: int) -> bool:
|
||||
"""
|
||||
Add experience points and check for level up.
|
||||
|
||||
Args:
|
||||
xp: Amount of experience to add
|
||||
|
||||
Returns:
|
||||
True if character leveled up, False otherwise
|
||||
"""
|
||||
self.experience += xp
|
||||
required_xp = self._calculate_xp_for_next_level()
|
||||
|
||||
if self.experience >= required_xp:
|
||||
self.level_up()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def level_up(self) -> None:
|
||||
"""
|
||||
Level up the character.
|
||||
|
||||
- Increases level
|
||||
- Resets experience to overflow amount
|
||||
- Could grant stat increases (future enhancement)
|
||||
"""
|
||||
required_xp = self._calculate_xp_for_next_level()
|
||||
overflow_xp = self.experience - required_xp
|
||||
|
||||
self.level += 1
|
||||
self.experience = overflow_xp
|
||||
|
||||
# Future: Apply stat increases based on class
|
||||
# For now, stats are increased manually via skill points
|
||||
|
||||
def _calculate_xp_for_next_level(self) -> int:
|
||||
"""
|
||||
Calculate XP required for next level.
|
||||
|
||||
Formula: 100 * (level ^ 1.5)
|
||||
This creates an exponential curve: 100, 282, 519, 800, 1118...
|
||||
|
||||
Returns:
|
||||
XP required for next level
|
||||
"""
|
||||
return int(100 * (self.level ** 1.5))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize character to dictionary for JSON storage.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all character data
|
||||
"""
|
||||
return {
|
||||
"character_id": self.character_id,
|
||||
"user_id": self.user_id,
|
||||
"name": self.name,
|
||||
"player_class": self.player_class.to_dict(),
|
||||
"origin": self.origin.to_dict(),
|
||||
"level": self.level,
|
||||
"experience": self.experience,
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
"unlocked_skills": self.unlocked_skills,
|
||||
"inventory": [item.to_dict() for item in self.inventory],
|
||||
"equipped": {slot: item.to_dict() for slot, item in self.equipped.items()},
|
||||
"gold": self.gold,
|
||||
"active_quests": self.active_quests,
|
||||
"discovered_locations": self.discovered_locations,
|
||||
"current_location": self.current_location,
|
||||
"npc_interactions": self.npc_interactions,
|
||||
# Computed properties for AI templates
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
}
|
||||
|
||||
def to_story_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize only story-relevant character data for AI prompts.
|
||||
|
||||
This trimmed version reduces token usage by excluding mechanical
|
||||
details that aren't needed for narrative generation (IDs, full
|
||||
inventory details, skill trees, etc.).
|
||||
|
||||
Returns:
|
||||
Dictionary containing story-relevant character data
|
||||
"""
|
||||
effective_stats = self.get_effective_stats()
|
||||
|
||||
# Get equipped item names for context (not full details)
|
||||
equipped_summary = {}
|
||||
for slot, item in self.equipped.items():
|
||||
equipped_summary[slot] = item.name
|
||||
|
||||
# Get skill names from unlocked skills
|
||||
skill_names = []
|
||||
all_skills = self.player_class.get_all_skills()
|
||||
for skill in all_skills:
|
||||
if skill.skill_id in self.unlocked_skills:
|
||||
skill_names.append({
|
||||
"name": skill.name,
|
||||
"level": 1 # Skills don't have levels, but template expects this
|
||||
})
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"level": self.level,
|
||||
"player_class": self.player_class.name,
|
||||
"origin_name": self.origin.name,
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
"gold": self.gold,
|
||||
# Stats for display and checks
|
||||
"stats": effective_stats.to_dict(),
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
# Simplified collections
|
||||
"skills": skill_names,
|
||||
"equipped": equipped_summary,
|
||||
"inventory_count": len(self.inventory),
|
||||
"active_quests_count": len(self.active_quests),
|
||||
# Empty list for templates that check completed_quests
|
||||
"effects": [], # Active effects passed separately in combat
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Character':
|
||||
"""
|
||||
Deserialize character from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing character data
|
||||
|
||||
Returns:
|
||||
Character instance
|
||||
"""
|
||||
from app.models.skills import PlayerClass
|
||||
|
||||
player_class = PlayerClass.from_dict(data["player_class"])
|
||||
origin = Origin.from_dict(data["origin"])
|
||||
base_stats = Stats.from_dict(data["base_stats"])
|
||||
inventory = [Item.from_dict(item) for item in data.get("inventory", [])]
|
||||
equipped = {slot: Item.from_dict(item) for slot, item in data.get("equipped", {}).items()}
|
||||
|
||||
return cls(
|
||||
character_id=data["character_id"],
|
||||
user_id=data["user_id"],
|
||||
name=data["name"],
|
||||
player_class=player_class,
|
||||
origin=origin,
|
||||
level=data.get("level", 1),
|
||||
experience=data.get("experience", 0),
|
||||
base_stats=base_stats,
|
||||
unlocked_skills=data.get("unlocked_skills", []),
|
||||
inventory=inventory,
|
||||
equipped=equipped,
|
||||
gold=data.get("gold", 0),
|
||||
active_quests=data.get("active_quests", []),
|
||||
discovered_locations=data.get("discovered_locations", []),
|
||||
current_location=data.get("current_location"),
|
||||
npc_interactions=data.get("npc_interactions", {}),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the character."""
|
||||
return (
|
||||
f"Character({self.name}, {self.player_class.name}, "
|
||||
f"Lv{self.level}, {self.gold}g)"
|
||||
)
|
||||
414
api/app/models/combat.py
Normal file
414
api/app/models/combat.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
Combat system data models.
|
||||
|
||||
This module defines the combat-related dataclasses including Combatant (a wrapper
|
||||
for characters/enemies in combat) and CombatEncounter (the combat state manager).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
import random
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.effects import Effect
|
||||
from app.models.abilities import Ability
|
||||
from app.models.enums import CombatStatus, EffectType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Combatant:
|
||||
"""
|
||||
Represents a character or enemy in combat.
|
||||
|
||||
This wraps either a player Character or an NPC/enemy for combat purposes,
|
||||
tracking combat-specific state like current HP/MP, active effects, and cooldowns.
|
||||
|
||||
Attributes:
|
||||
combatant_id: Unique identifier (character_id or enemy_id)
|
||||
name: Display name
|
||||
is_player: True if player character, False if NPC/enemy
|
||||
current_hp: Current hit points
|
||||
max_hp: Maximum hit points
|
||||
current_mp: Current mana points
|
||||
max_mp: Maximum mana points
|
||||
stats: Current combat stats (use get_effective_stats() from Character)
|
||||
active_effects: Effects currently applied to this combatant
|
||||
abilities: Available abilities for this combatant
|
||||
cooldowns: Map of ability_id to turns remaining
|
||||
initiative: Turn order value (rolled at combat start)
|
||||
"""
|
||||
|
||||
combatant_id: str
|
||||
name: str
|
||||
is_player: bool
|
||||
current_hp: int
|
||||
max_hp: int
|
||||
current_mp: int
|
||||
max_mp: int
|
||||
stats: Stats
|
||||
active_effects: List[Effect] = field(default_factory=list)
|
||||
abilities: List[str] = field(default_factory=list) # ability_ids
|
||||
cooldowns: Dict[str, int] = field(default_factory=dict)
|
||||
initiative: int = 0
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if combatant is still alive."""
|
||||
return self.current_hp > 0
|
||||
|
||||
def is_dead(self) -> bool:
|
||||
"""Check if combatant is dead."""
|
||||
return self.current_hp <= 0
|
||||
|
||||
def is_stunned(self) -> bool:
|
||||
"""Check if combatant is stunned and cannot act."""
|
||||
return any(e.effect_type == EffectType.STUN for e in self.active_effects)
|
||||
|
||||
def take_damage(self, damage: int) -> int:
|
||||
"""
|
||||
Apply damage to this combatant.
|
||||
|
||||
Damage is reduced by shields first, then HP.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage to apply
|
||||
|
||||
Returns:
|
||||
Actual damage dealt to HP (after shields)
|
||||
"""
|
||||
remaining_damage = damage
|
||||
|
||||
# Apply shield absorption
|
||||
for effect in self.active_effects:
|
||||
if effect.effect_type == EffectType.SHIELD and remaining_damage > 0:
|
||||
remaining_damage = effect.reduce_shield(remaining_damage)
|
||||
|
||||
# Apply remaining damage to HP
|
||||
hp_damage = min(remaining_damage, self.current_hp)
|
||||
self.current_hp -= hp_damage
|
||||
|
||||
return hp_damage
|
||||
|
||||
def heal(self, amount: int) -> int:
|
||||
"""
|
||||
Heal this combatant.
|
||||
|
||||
Args:
|
||||
amount: Amount to heal
|
||||
|
||||
Returns:
|
||||
Actual amount healed (capped at max_hp)
|
||||
"""
|
||||
old_hp = self.current_hp
|
||||
self.current_hp = min(self.max_hp, self.current_hp + amount)
|
||||
return self.current_hp - old_hp
|
||||
|
||||
def restore_mana(self, amount: int) -> int:
|
||||
"""
|
||||
Restore mana to this combatant.
|
||||
|
||||
Args:
|
||||
amount: Amount to restore
|
||||
|
||||
Returns:
|
||||
Actual amount restored (capped at max_mp)
|
||||
"""
|
||||
old_mp = self.current_mp
|
||||
self.current_mp = min(self.max_mp, self.current_mp + amount)
|
||||
return self.current_mp - old_mp
|
||||
|
||||
def can_use_ability(self, ability_id: str, ability: Ability) -> bool:
|
||||
"""
|
||||
Check if ability can be used right now.
|
||||
|
||||
Args:
|
||||
ability_id: Ability identifier
|
||||
ability: Ability instance
|
||||
|
||||
Returns:
|
||||
True if ability can be used, False otherwise
|
||||
"""
|
||||
# Check if ability is available to this combatant
|
||||
if ability_id not in self.abilities:
|
||||
return False
|
||||
|
||||
# Check mana cost
|
||||
if self.current_mp < ability.mana_cost:
|
||||
return False
|
||||
|
||||
# Check cooldown
|
||||
if ability_id in self.cooldowns and self.cooldowns[ability_id] > 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def use_ability_cost(self, ability: Ability, ability_id: str) -> None:
|
||||
"""
|
||||
Apply the costs of using an ability (mana, cooldown).
|
||||
|
||||
Args:
|
||||
ability: Ability being used
|
||||
ability_id: Ability identifier
|
||||
"""
|
||||
# Consume mana
|
||||
self.current_mp -= ability.mana_cost
|
||||
|
||||
# Set cooldown
|
||||
if ability.cooldown > 0:
|
||||
self.cooldowns[ability_id] = ability.cooldown
|
||||
|
||||
def tick_effects(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process all active effects for this turn.
|
||||
|
||||
Returns:
|
||||
List of effect tick results
|
||||
"""
|
||||
results = []
|
||||
expired_effects = []
|
||||
|
||||
for effect in self.active_effects:
|
||||
result = effect.tick()
|
||||
|
||||
# Apply effect results
|
||||
if effect.effect_type == EffectType.DOT:
|
||||
self.take_damage(result["value"])
|
||||
elif effect.effect_type == EffectType.HOT:
|
||||
self.heal(result["value"])
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Mark expired effects for removal
|
||||
if result.get("expired", False):
|
||||
expired_effects.append(effect)
|
||||
|
||||
# Remove expired effects
|
||||
for effect in expired_effects:
|
||||
self.active_effects.remove(effect)
|
||||
|
||||
return results
|
||||
|
||||
def tick_cooldowns(self) -> None:
|
||||
"""Reduce all ability cooldowns by 1 turn."""
|
||||
for ability_id in list(self.cooldowns.keys()):
|
||||
self.cooldowns[ability_id] -= 1
|
||||
if self.cooldowns[ability_id] <= 0:
|
||||
del self.cooldowns[ability_id]
|
||||
|
||||
def add_effect(self, effect: Effect) -> None:
|
||||
"""
|
||||
Add an effect to this combatant.
|
||||
|
||||
If the same effect already exists, stack it instead.
|
||||
|
||||
Args:
|
||||
effect: Effect to add
|
||||
"""
|
||||
# Check if effect already exists
|
||||
for existing in self.active_effects:
|
||||
if existing.name == effect.name and existing.effect_type == effect.effect_type:
|
||||
# Stack the effect
|
||||
existing.apply_stack(effect.duration)
|
||||
return
|
||||
|
||||
# New effect, add it
|
||||
self.active_effects.append(effect)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize combatant to dictionary."""
|
||||
return {
|
||||
"combatant_id": self.combatant_id,
|
||||
"name": self.name,
|
||||
"is_player": self.is_player,
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
"current_mp": self.current_mp,
|
||||
"max_mp": self.max_mp,
|
||||
"stats": self.stats.to_dict(),
|
||||
"active_effects": [e.to_dict() for e in self.active_effects],
|
||||
"abilities": self.abilities,
|
||||
"cooldowns": self.cooldowns,
|
||||
"initiative": self.initiative,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Combatant':
|
||||
"""Deserialize combatant from dictionary."""
|
||||
stats = Stats.from_dict(data["stats"])
|
||||
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
||||
|
||||
return cls(
|
||||
combatant_id=data["combatant_id"],
|
||||
name=data["name"],
|
||||
is_player=data["is_player"],
|
||||
current_hp=data["current_hp"],
|
||||
max_hp=data["max_hp"],
|
||||
current_mp=data["current_mp"],
|
||||
max_mp=data["max_mp"],
|
||||
stats=stats,
|
||||
active_effects=active_effects,
|
||||
abilities=data.get("abilities", []),
|
||||
cooldowns=data.get("cooldowns", {}),
|
||||
initiative=data.get("initiative", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombatEncounter:
|
||||
"""
|
||||
Represents a combat encounter state.
|
||||
|
||||
Manages turn order, combatants, combat log, and victory/defeat conditions.
|
||||
|
||||
Attributes:
|
||||
encounter_id: Unique identifier
|
||||
combatants: All fighters in this combat
|
||||
turn_order: Combatant IDs sorted by initiative (highest first)
|
||||
current_turn_index: Index in turn_order for current turn
|
||||
round_number: Current round (increments each full turn cycle)
|
||||
combat_log: History of all actions taken
|
||||
status: Current combat status (active, victory, defeat, fled)
|
||||
"""
|
||||
|
||||
encounter_id: str
|
||||
combatants: List[Combatant] = field(default_factory=list)
|
||||
turn_order: List[str] = field(default_factory=list)
|
||||
current_turn_index: int = 0
|
||||
round_number: int = 1
|
||||
combat_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
status: CombatStatus = CombatStatus.ACTIVE
|
||||
|
||||
def initialize_combat(self) -> None:
|
||||
"""
|
||||
Initialize combat by rolling initiative and setting turn order.
|
||||
|
||||
Initiative: d20 + dexterity bonus
|
||||
"""
|
||||
# Roll initiative for all combatants
|
||||
for combatant in self.combatants:
|
||||
# d20 + dexterity bonus
|
||||
roll = random.randint(1, 20)
|
||||
dex_bonus = combatant.stats.dexterity // 2
|
||||
combatant.initiative = roll + dex_bonus
|
||||
|
||||
# Sort combatants by initiative (highest first)
|
||||
sorted_combatants = sorted(self.combatants, key=lambda c: c.initiative, reverse=True)
|
||||
self.turn_order = [c.combatant_id for c in sorted_combatants]
|
||||
|
||||
self.log_action("combat_start", None, f"Combat begins! Round {self.round_number}")
|
||||
|
||||
def get_current_combatant(self) -> Optional[Combatant]:
|
||||
"""Get the combatant whose turn it currently is."""
|
||||
if not self.turn_order:
|
||||
return None
|
||||
|
||||
current_id = self.turn_order[self.current_turn_index]
|
||||
return self.get_combatant(current_id)
|
||||
|
||||
def get_combatant(self, combatant_id: str) -> Optional[Combatant]:
|
||||
"""Get a combatant by ID."""
|
||||
for combatant in self.combatants:
|
||||
if combatant.combatant_id == combatant_id:
|
||||
return combatant
|
||||
return None
|
||||
|
||||
def advance_turn(self) -> None:
|
||||
"""Advance to the next combatant's turn."""
|
||||
self.current_turn_index += 1
|
||||
|
||||
# If we've cycled through all combatants, start a new round
|
||||
if self.current_turn_index >= len(self.turn_order):
|
||||
self.current_turn_index = 0
|
||||
self.round_number += 1
|
||||
self.log_action("round_start", None, f"Round {self.round_number} begins")
|
||||
|
||||
def start_turn(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process the start of a turn.
|
||||
|
||||
- Tick all effects on current combatant
|
||||
- Tick cooldowns
|
||||
- Check for stun
|
||||
|
||||
Returns:
|
||||
List of effect tick results
|
||||
"""
|
||||
combatant = self.get_current_combatant()
|
||||
if not combatant:
|
||||
return []
|
||||
|
||||
# Process effects
|
||||
effect_results = combatant.tick_effects()
|
||||
|
||||
# Reduce cooldowns
|
||||
combatant.tick_cooldowns()
|
||||
|
||||
return effect_results
|
||||
|
||||
def check_end_condition(self) -> CombatStatus:
|
||||
"""
|
||||
Check if combat should end.
|
||||
|
||||
Victory: All enemy combatants dead
|
||||
Defeat: All player combatants dead
|
||||
|
||||
Returns:
|
||||
Updated combat status
|
||||
"""
|
||||
players_alive = any(c.is_alive() and c.is_player for c in self.combatants)
|
||||
enemies_alive = any(c.is_alive() and not c.is_player for c in self.combatants)
|
||||
|
||||
if not enemies_alive and players_alive:
|
||||
self.status = CombatStatus.VICTORY
|
||||
self.log_action("combat_end", None, "Victory! All enemies defeated!")
|
||||
elif not players_alive:
|
||||
self.status = CombatStatus.DEFEAT
|
||||
self.log_action("combat_end", None, "Defeat! All players have fallen!")
|
||||
|
||||
return self.status
|
||||
|
||||
def log_action(self, action_type: str, combatant_id: Optional[str], message: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""
|
||||
Log a combat action.
|
||||
|
||||
Args:
|
||||
action_type: Type of action (attack, spell, item_use, etc.)
|
||||
combatant_id: ID of acting combatant (or None for system messages)
|
||||
message: Human-readable message
|
||||
details: Additional action details
|
||||
"""
|
||||
entry = {
|
||||
"round": self.round_number,
|
||||
"action_type": action_type,
|
||||
"combatant_id": combatant_id,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
}
|
||||
self.combat_log.append(entry)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize combat encounter to dictionary."""
|
||||
return {
|
||||
"encounter_id": self.encounter_id,
|
||||
"combatants": [c.to_dict() for c in self.combatants],
|
||||
"turn_order": self.turn_order,
|
||||
"current_turn_index": self.current_turn_index,
|
||||
"round_number": self.round_number,
|
||||
"combat_log": self.combat_log,
|
||||
"status": self.status.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'CombatEncounter':
|
||||
"""Deserialize combat encounter from dictionary."""
|
||||
combatants = [Combatant.from_dict(c) for c in data.get("combatants", [])]
|
||||
status = CombatStatus(data.get("status", "active"))
|
||||
|
||||
return cls(
|
||||
encounter_id=data["encounter_id"],
|
||||
combatants=combatants,
|
||||
turn_order=data.get("turn_order", []),
|
||||
current_turn_index=data.get("current_turn_index", 0),
|
||||
round_number=data.get("round_number", 1),
|
||||
combat_log=data.get("combat_log", []),
|
||||
status=status,
|
||||
)
|
||||
208
api/app/models/effects.py
Normal file
208
api/app/models/effects.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Effect system for temporary status modifiers in combat.
|
||||
|
||||
This module defines the Effect dataclass which represents temporary buffs,
|
||||
debuffs, damage over time, healing over time, stuns, and shields.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Any, Optional
|
||||
from app.models.enums import EffectType, StatType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Effect:
|
||||
"""
|
||||
Represents a temporary effect applied to a combatant.
|
||||
|
||||
Effects are processed at the start of each turn via the tick() method.
|
||||
They can stack up to max_stacks, and duration refreshes on re-application.
|
||||
|
||||
Attributes:
|
||||
effect_id: Unique identifier for this effect instance
|
||||
name: Display name of the effect
|
||||
effect_type: Type of effect (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD)
|
||||
duration: Turns remaining before effect expires
|
||||
power: Damage/healing per turn OR stat modifier amount
|
||||
stat_affected: Which stat is modified (for BUFF/DEBUFF only)
|
||||
stacks: Number of times this effect has been stacked
|
||||
max_stacks: Maximum number of stacks allowed (default 5)
|
||||
source: Who/what applied this effect (character_id or ability_id)
|
||||
"""
|
||||
|
||||
effect_id: str
|
||||
name: str
|
||||
effect_type: EffectType
|
||||
duration: int
|
||||
power: int
|
||||
stat_affected: Optional[StatType] = None
|
||||
stacks: int = 1
|
||||
max_stacks: int = 5
|
||||
source: str = ""
|
||||
|
||||
def tick(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Process one turn of this effect.
|
||||
|
||||
Returns a dictionary describing what happened this turn, including:
|
||||
- effect_name: Name of the effect
|
||||
- effect_type: Type of effect
|
||||
- value: Damage dealt (DOT) or healing done (HOT)
|
||||
- shield_remaining: Current shield strength (SHIELD only)
|
||||
- stunned: True if this is a stun effect (STUN only)
|
||||
- stat_modifier: Amount stats are modified (BUFF/DEBUFF only)
|
||||
- expired: True if effect duration reached 0
|
||||
|
||||
Returns:
|
||||
Dictionary with effect processing results
|
||||
"""
|
||||
result = {
|
||||
"effect_name": self.name,
|
||||
"effect_type": self.effect_type.value,
|
||||
"value": 0,
|
||||
"expired": False,
|
||||
}
|
||||
|
||||
# Process effect based on type
|
||||
if self.effect_type == EffectType.DOT:
|
||||
# Damage over time: deal damage equal to power × stacks
|
||||
result["value"] = self.power * self.stacks
|
||||
result["message"] = f"{self.name} deals {result['value']} damage"
|
||||
|
||||
elif self.effect_type == EffectType.HOT:
|
||||
# Heal over time: heal equal to power × stacks
|
||||
result["value"] = self.power * self.stacks
|
||||
result["message"] = f"{self.name} heals {result['value']} HP"
|
||||
|
||||
elif self.effect_type == EffectType.STUN:
|
||||
# Stun: prevents actions this turn
|
||||
result["stunned"] = True
|
||||
result["message"] = f"{self.name} prevents actions"
|
||||
|
||||
elif self.effect_type == EffectType.SHIELD:
|
||||
# Shield: absorbs damage (power × stacks = shield strength)
|
||||
result["shield_remaining"] = self.power * self.stacks
|
||||
result["message"] = f"{self.name} absorbs up to {result['shield_remaining']} damage"
|
||||
|
||||
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
# Buff/Debuff: modify stats
|
||||
result["stat_affected"] = self.stat_affected.value if self.stat_affected else None
|
||||
result["stat_modifier"] = self.power * self.stacks
|
||||
if self.effect_type == EffectType.BUFF:
|
||||
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
||||
else:
|
||||
result["message"] = f"{self.name} decreases {result['stat_affected']} by {result['stat_modifier']}"
|
||||
|
||||
# Decrease duration
|
||||
self.duration -= 1
|
||||
if self.duration <= 0:
|
||||
result["expired"] = True
|
||||
result["message"] = f"{self.name} has expired"
|
||||
|
||||
return result
|
||||
|
||||
def apply_stack(self, additional_duration: int = 0) -> None:
|
||||
"""
|
||||
Apply an additional stack of this effect.
|
||||
|
||||
Increases stack count (up to max_stacks) and refreshes duration.
|
||||
If additional_duration is provided, it's added to current duration.
|
||||
|
||||
Args:
|
||||
additional_duration: Extra turns to add (default 0 = refresh only)
|
||||
"""
|
||||
if self.stacks < self.max_stacks:
|
||||
self.stacks += 1
|
||||
|
||||
# Refresh duration or extend it
|
||||
if additional_duration > 0:
|
||||
self.duration = max(self.duration, additional_duration)
|
||||
else:
|
||||
# Find the base duration (current + turns already consumed)
|
||||
# For refresh behavior, we'd need to store original_duration
|
||||
# For now, just use the provided duration
|
||||
pass
|
||||
|
||||
def reduce_shield(self, damage: int) -> int:
|
||||
"""
|
||||
Reduce shield strength by damage amount.
|
||||
|
||||
Only applicable for SHIELD effects. Returns remaining damage after shield.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage to absorb
|
||||
|
||||
Returns:
|
||||
Remaining damage after shield absorption
|
||||
"""
|
||||
if self.effect_type != EffectType.SHIELD:
|
||||
return damage
|
||||
|
||||
shield_strength = self.power * self.stacks
|
||||
if damage >= shield_strength:
|
||||
# Shield breaks completely
|
||||
remaining_damage = damage - shield_strength
|
||||
self.power = 0 # Shield depleted
|
||||
self.duration = 0 # Effect expires
|
||||
return remaining_damage
|
||||
else:
|
||||
# Shield partially absorbs damage
|
||||
damage_per_stack = damage / self.stacks
|
||||
self.power = max(0, int(self.power - damage_per_stack))
|
||||
return 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize effect to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all effect data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["effect_type"] = self.effect_type.value
|
||||
if self.stat_affected:
|
||||
data["stat_affected"] = self.stat_affected.value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Effect':
|
||||
"""
|
||||
Deserialize effect from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing effect data
|
||||
|
||||
Returns:
|
||||
Effect instance
|
||||
"""
|
||||
# Convert string values back to enums
|
||||
effect_type = EffectType(data["effect_type"])
|
||||
stat_affected = StatType(data["stat_affected"]) if data.get("stat_affected") else None
|
||||
|
||||
return cls(
|
||||
effect_id=data["effect_id"],
|
||||
name=data["name"],
|
||||
effect_type=effect_type,
|
||||
duration=data["duration"],
|
||||
power=data["power"],
|
||||
stat_affected=stat_affected,
|
||||
stacks=data.get("stacks", 1),
|
||||
max_stacks=data.get("max_stacks", 5),
|
||||
source=data.get("source", ""),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the effect."""
|
||||
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
return (
|
||||
f"Effect({self.name}, {self.effect_type.value}, "
|
||||
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
|
||||
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
||||
f"{self.duration}t, {self.stacks}x)"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"Effect({self.name}, {self.effect_type.value}, "
|
||||
f"power={self.power * self.stacks}, "
|
||||
f"duration={self.duration}t, stacks={self.stacks}x)"
|
||||
)
|
||||
113
api/app/models/enums.py
Normal file
113
api/app/models/enums.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Enumeration types for the Code of Conquest game system.
|
||||
|
||||
This module defines all enum types used throughout the data models to ensure
|
||||
type safety and prevent invalid values.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EffectType(Enum):
|
||||
"""Types of effects that can be applied to combatants."""
|
||||
|
||||
BUFF = "buff" # Temporarily increase stats
|
||||
DEBUFF = "debuff" # Temporarily decrease stats
|
||||
DOT = "dot" # Damage over time (poison, bleed, burn)
|
||||
HOT = "hot" # Heal over time (regeneration)
|
||||
STUN = "stun" # Prevent actions (skip turn)
|
||||
SHIELD = "shield" # Absorb damage before HP loss
|
||||
|
||||
|
||||
class DamageType(Enum):
|
||||
"""Types of damage that can be dealt in combat."""
|
||||
|
||||
PHYSICAL = "physical" # Standard weapon damage
|
||||
FIRE = "fire" # Fire-based magic damage
|
||||
ICE = "ice" # Ice-based magic damage
|
||||
LIGHTNING = "lightning" # Lightning-based magic damage
|
||||
HOLY = "holy" # Holy/divine damage
|
||||
SHADOW = "shadow" # Dark/shadow magic damage
|
||||
POISON = "poison" # Poison damage (usually DoT)
|
||||
|
||||
|
||||
class ItemType(Enum):
|
||||
"""Categories of items in the game."""
|
||||
|
||||
WEAPON = "weapon" # Adds damage, may have special effects
|
||||
ARMOR = "armor" # Adds defense/resistance
|
||||
CONSUMABLE = "consumable" # One-time use (potions, scrolls)
|
||||
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
|
||||
|
||||
|
||||
class StatType(Enum):
|
||||
"""Character attribute types."""
|
||||
|
||||
STRENGTH = "strength" # Physical power
|
||||
DEXTERITY = "dexterity" # Agility and precision
|
||||
CONSTITUTION = "constitution" # Endurance and health
|
||||
INTELLIGENCE = "intelligence" # Magical power
|
||||
WISDOM = "wisdom" # Perception and insight
|
||||
CHARISMA = "charisma" # Social influence
|
||||
|
||||
|
||||
class AbilityType(Enum):
|
||||
"""Categories of abilities that can be used in combat or exploration."""
|
||||
|
||||
ATTACK = "attack" # Basic physical attack
|
||||
SPELL = "spell" # Magical spell
|
||||
SKILL = "skill" # Special class ability
|
||||
ITEM_USE = "item_use" # Using a consumable item
|
||||
DEFEND = "defend" # Defensive action
|
||||
|
||||
|
||||
class CombatStatus(Enum):
|
||||
"""Status of a combat encounter."""
|
||||
|
||||
ACTIVE = "active" # Combat is ongoing
|
||||
VICTORY = "victory" # Player(s) won
|
||||
DEFEAT = "defeat" # Player(s) lost
|
||||
FLED = "fled" # Player(s) escaped
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
"""Status of a game session."""
|
||||
|
||||
ACTIVE = "active" # Session is ongoing
|
||||
COMPLETED = "completed" # Session ended normally
|
||||
TIMEOUT = "timeout" # Session ended due to inactivity
|
||||
|
||||
|
||||
class ListingStatus(Enum):
|
||||
"""Status of a marketplace listing."""
|
||||
|
||||
ACTIVE = "active" # Listing is live
|
||||
SOLD = "sold" # Item has been sold
|
||||
EXPIRED = "expired" # Listing time ran out
|
||||
REMOVED = "removed" # Seller cancelled listing
|
||||
|
||||
|
||||
class ListingType(Enum):
|
||||
"""Type of marketplace listing."""
|
||||
|
||||
AUCTION = "auction" # Bidding system
|
||||
FIXED_PRICE = "fixed_price" # Immediate purchase at set price
|
||||
|
||||
|
||||
class SessionType(Enum):
|
||||
"""Type of game session."""
|
||||
|
||||
SOLO = "solo" # Single-player session
|
||||
MULTIPLAYER = "multiplayer" # Multi-player party session
|
||||
|
||||
|
||||
class LocationType(Enum):
|
||||
"""Types of locations in the game world."""
|
||||
|
||||
TOWN = "town" # Town or city
|
||||
TAVERN = "tavern" # Tavern or inn
|
||||
WILDERNESS = "wilderness" # Outdoor wilderness areas
|
||||
DUNGEON = "dungeon" # Underground dungeons/caves
|
||||
RUINS = "ruins" # Ancient ruins
|
||||
LIBRARY = "library" # Library or archive
|
||||
SAFE_AREA = "safe_area" # Safe rest areas
|
||||
196
api/app/models/items.py
Normal file
196
api/app/models/items.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Item system for equipment, consumables, and quest items.
|
||||
|
||||
This module defines the Item dataclass representing all types of items in the game,
|
||||
including weapons, armor, consumables, and quest items.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.enums import ItemType, DamageType
|
||||
from app.models.effects import Effect
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
"""
|
||||
Represents an item in the game (weapon, armor, consumable, or quest item).
|
||||
|
||||
Items can provide passive stat bonuses when equipped, have weapon/armor stats,
|
||||
or provide effects when consumed.
|
||||
|
||||
Attributes:
|
||||
item_id: Unique identifier
|
||||
name: Display name
|
||||
item_type: Category (weapon, armor, consumable, quest_item)
|
||||
description: Item lore and information
|
||||
value: Gold value for buying/selling
|
||||
is_tradeable: Whether item can be sold on marketplace
|
||||
stat_bonuses: Passive bonuses to stats when equipped
|
||||
Example: {"strength": 5, "constitution": 3}
|
||||
effects_on_use: Effects applied when consumed (consumables only)
|
||||
|
||||
Weapon-specific attributes:
|
||||
damage: Base weapon damage
|
||||
damage_type: Type of damage (physical, fire, etc.)
|
||||
crit_chance: Probability of critical hit (0.0 to 1.0)
|
||||
crit_multiplier: Damage multiplier on critical hit
|
||||
|
||||
Armor-specific attributes:
|
||||
defense: Physical defense bonus
|
||||
resistance: Magical resistance bonus
|
||||
|
||||
Requirements (future):
|
||||
required_level: Minimum character level to use
|
||||
required_class: Class restriction (if any)
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
name: str
|
||||
item_type: ItemType
|
||||
description: str
|
||||
value: int = 0
|
||||
is_tradeable: bool = True
|
||||
|
||||
# Passive bonuses (equipment)
|
||||
stat_bonuses: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Active effects (consumables)
|
||||
effects_on_use: List[Effect] = field(default_factory=list)
|
||||
|
||||
# Weapon-specific
|
||||
damage: int = 0
|
||||
damage_type: Optional[DamageType] = None
|
||||
crit_chance: float = 0.05 # 5% default critical hit chance
|
||||
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
||||
|
||||
# Armor-specific
|
||||
defense: int = 0
|
||||
resistance: int = 0
|
||||
|
||||
# Requirements (future expansion)
|
||||
required_level: int = 1
|
||||
required_class: Optional[str] = None
|
||||
|
||||
def is_weapon(self) -> bool:
|
||||
"""Check if this item is a weapon."""
|
||||
return self.item_type == ItemType.WEAPON
|
||||
|
||||
def is_armor(self) -> bool:
|
||||
"""Check if this item is armor."""
|
||||
return self.item_type == ItemType.ARMOR
|
||||
|
||||
def is_consumable(self) -> bool:
|
||||
"""Check if this item is a consumable."""
|
||||
return self.item_type == ItemType.CONSUMABLE
|
||||
|
||||
def is_quest_item(self) -> bool:
|
||||
"""Check if this item is a quest item."""
|
||||
return self.item_type == ItemType.QUEST_ITEM
|
||||
|
||||
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if a character can equip this item.
|
||||
|
||||
Args:
|
||||
character_level: Character's current level
|
||||
character_class: Character's class (if class restrictions exist)
|
||||
|
||||
Returns:
|
||||
True if item can be equipped, False otherwise
|
||||
"""
|
||||
# Check level requirement
|
||||
if character_level < self.required_level:
|
||||
return False
|
||||
|
||||
# Check class requirement
|
||||
if self.required_class and character_class != self.required_class:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_total_stat_bonus(self, stat_name: str) -> int:
|
||||
"""
|
||||
Get the total bonus for a specific stat from this item.
|
||||
|
||||
Args:
|
||||
stat_name: Name of the stat (e.g., "strength", "intelligence")
|
||||
|
||||
Returns:
|
||||
Bonus value for that stat (0 if not present)
|
||||
"""
|
||||
return self.stat_bonuses.get(stat_name, 0)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize item to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all item data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["item_type"] = self.item_type.value
|
||||
if self.damage_type:
|
||||
data["damage_type"] = self.damage_type.value
|
||||
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Item':
|
||||
"""
|
||||
Deserialize item from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing item data
|
||||
|
||||
Returns:
|
||||
Item instance
|
||||
"""
|
||||
# Convert string values back to enums
|
||||
item_type = ItemType(data["item_type"])
|
||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||
|
||||
# Deserialize effects
|
||||
effects = []
|
||||
if "effects_on_use" in data and data["effects_on_use"]:
|
||||
effects = [Effect.from_dict(e) for e in data["effects_on_use"]]
|
||||
|
||||
return cls(
|
||||
item_id=data["item_id"],
|
||||
name=data["name"],
|
||||
item_type=item_type,
|
||||
description=data["description"],
|
||||
value=data.get("value", 0),
|
||||
is_tradeable=data.get("is_tradeable", True),
|
||||
stat_bonuses=data.get("stat_bonuses", {}),
|
||||
effects_on_use=effects,
|
||||
damage=data.get("damage", 0),
|
||||
damage_type=damage_type,
|
||||
crit_chance=data.get("crit_chance", 0.05),
|
||||
crit_multiplier=data.get("crit_multiplier", 2.0),
|
||||
defense=data.get("defense", 0),
|
||||
resistance=data.get("resistance", 0),
|
||||
required_level=data.get("required_level", 1),
|
||||
required_class=data.get("required_class"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the item."""
|
||||
if self.is_weapon():
|
||||
return (
|
||||
f"Item({self.name}, weapon, dmg={self.damage}, "
|
||||
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
||||
)
|
||||
elif self.is_armor():
|
||||
return (
|
||||
f"Item({self.name}, armor, def={self.defense}, "
|
||||
f"res={self.resistance}, value={self.value}g)"
|
||||
)
|
||||
elif self.is_consumable():
|
||||
return (
|
||||
f"Item({self.name}, consumable, "
|
||||
f"effects={len(self.effects_on_use)}, value={self.value}g)"
|
||||
)
|
||||
else:
|
||||
return f"Item({self.name}, quest_item)"
|
||||
181
api/app/models/location.py
Normal file
181
api/app/models/location.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Location data models for the world exploration system.
|
||||
|
||||
This module defines Location and Region dataclasses that represent structured
|
||||
game world data. Locations are loaded from YAML files and provide rich context
|
||||
for AI narrative generation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from app.models.enums import LocationType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
"""
|
||||
Represents a defined location in the game world.
|
||||
|
||||
Locations are persistent world entities with NPCs, quests, and connections
|
||||
to other locations. They are loaded from YAML files at runtime.
|
||||
|
||||
Attributes:
|
||||
location_id: Unique identifier (e.g., "crossville_tavern")
|
||||
name: Display name (e.g., "The Rusty Anchor Tavern")
|
||||
location_type: Type of location (town, tavern, wilderness, dungeon, etc.)
|
||||
region_id: Parent region this location belongs to
|
||||
description: Full description for AI narrative context
|
||||
lore: Optional historical/background information
|
||||
ambient_description: Atmospheric details for AI narration
|
||||
available_quests: Quest IDs that can be discovered at this location
|
||||
npc_ids: List of NPC IDs present at this location
|
||||
discoverable_locations: Location IDs that can be revealed from here
|
||||
is_starting_location: Whether this is a valid origin starting point
|
||||
tags: Additional metadata tags for filtering/categorization
|
||||
"""
|
||||
|
||||
location_id: str
|
||||
name: str
|
||||
location_type: LocationType
|
||||
region_id: str
|
||||
description: str
|
||||
lore: Optional[str] = None
|
||||
ambient_description: Optional[str] = None
|
||||
available_quests: List[str] = field(default_factory=list)
|
||||
npc_ids: List[str] = field(default_factory=list)
|
||||
discoverable_locations: List[str] = field(default_factory=list)
|
||||
is_starting_location: bool = False
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize location to dictionary for JSON responses.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all location data
|
||||
"""
|
||||
return {
|
||||
"location_id": self.location_id,
|
||||
"name": self.name,
|
||||
"location_type": self.location_type.value,
|
||||
"region_id": self.region_id,
|
||||
"description": self.description,
|
||||
"lore": self.lore,
|
||||
"ambient_description": self.ambient_description,
|
||||
"available_quests": self.available_quests,
|
||||
"npc_ids": self.npc_ids,
|
||||
"discoverable_locations": self.discoverable_locations,
|
||||
"is_starting_location": self.is_starting_location,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
def to_story_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize location for AI narrative context.
|
||||
|
||||
Returns a trimmed version with only narrative-relevant data
|
||||
to reduce token usage in AI prompts.
|
||||
|
||||
Returns:
|
||||
Dictionary containing story-relevant location data
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"type": self.location_type.value,
|
||||
"description": self.description,
|
||||
"ambient": self.ambient_description,
|
||||
"lore": self.lore,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Location':
|
||||
"""
|
||||
Deserialize location from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing location data (from YAML or JSON)
|
||||
|
||||
Returns:
|
||||
Location instance
|
||||
"""
|
||||
# Handle location_type - can be string or LocationType enum
|
||||
location_type = data.get("location_type", "town")
|
||||
if isinstance(location_type, str):
|
||||
location_type = LocationType(location_type)
|
||||
|
||||
return cls(
|
||||
location_id=data["location_id"],
|
||||
name=data["name"],
|
||||
location_type=location_type,
|
||||
region_id=data["region_id"],
|
||||
description=data["description"],
|
||||
lore=data.get("lore"),
|
||||
ambient_description=data.get("ambient_description"),
|
||||
available_quests=data.get("available_quests", []),
|
||||
npc_ids=data.get("npc_ids", []),
|
||||
discoverable_locations=data.get("discoverable_locations", []),
|
||||
is_starting_location=data.get("is_starting_location", False),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the location."""
|
||||
return f"Location({self.location_id}, {self.name}, {self.location_type.value})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Region:
|
||||
"""
|
||||
Represents a geographical region containing multiple locations.
|
||||
|
||||
Regions group related locations together for organizational purposes
|
||||
and can contain region-wide lore or events.
|
||||
|
||||
Attributes:
|
||||
region_id: Unique identifier (e.g., "crossville")
|
||||
name: Display name (e.g., "Crossville Province")
|
||||
description: Region overview and atmosphere
|
||||
location_ids: List of all location IDs in this region
|
||||
"""
|
||||
|
||||
region_id: str
|
||||
name: str
|
||||
description: str
|
||||
location_ids: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize region to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all region data
|
||||
"""
|
||||
return {
|
||||
"region_id": self.region_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"location_ids": self.location_ids,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Region':
|
||||
"""
|
||||
Deserialize region from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing region data
|
||||
|
||||
Returns:
|
||||
Region instance
|
||||
"""
|
||||
return cls(
|
||||
region_id=data["region_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
location_ids=data.get("location_ids", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the region."""
|
||||
return f"Region({self.region_id}, {self.name}, {len(self.location_ids)} locations)"
|
||||
401
api/app/models/marketplace.py
Normal file
401
api/app/models/marketplace.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Marketplace and economy data models.
|
||||
|
||||
This module defines the marketplace-related dataclasses including
|
||||
MarketplaceListing, Bid, Transaction, and ShopItem for the player economy.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.items import Item
|
||||
from app.models.enums import ListingType, ListingStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bid:
|
||||
"""
|
||||
Represents a bid on an auction listing.
|
||||
|
||||
Attributes:
|
||||
bidder_id: User ID of the bidder
|
||||
bidder_name: Character name of the bidder
|
||||
amount: Bid amount in gold
|
||||
timestamp: ISO timestamp of when bid was placed
|
||||
"""
|
||||
|
||||
bidder_id: str
|
||||
bidder_name: str
|
||||
amount: int
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize bid to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Bid':
|
||||
"""Deserialize bid from dictionary."""
|
||||
return cls(
|
||||
bidder_id=data["bidder_id"],
|
||||
bidder_name=data["bidder_name"],
|
||||
amount=data["amount"],
|
||||
timestamp=data.get("timestamp", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketplaceListing:
|
||||
"""
|
||||
Represents an item listing on the player marketplace.
|
||||
|
||||
Supports both fixed-price and auction-style listings.
|
||||
|
||||
Attributes:
|
||||
listing_id: Unique identifier
|
||||
seller_id: User ID of the seller
|
||||
character_id: Character ID of the seller
|
||||
item_data: Full item details being sold
|
||||
listing_type: "auction" or "fixed_price"
|
||||
price: For fixed_price listings
|
||||
starting_bid: Minimum bid for auction listings
|
||||
current_bid: Current highest bid for auction listings
|
||||
buyout_price: Optional instant-buy price for auctions
|
||||
bids: Bid history for auction listings
|
||||
auction_end: ISO timestamp when auction ends
|
||||
status: Listing status (active, sold, expired, removed)
|
||||
created_at: ISO timestamp of listing creation
|
||||
"""
|
||||
|
||||
listing_id: str
|
||||
seller_id: str
|
||||
character_id: str
|
||||
item_data: Item
|
||||
listing_type: ListingType
|
||||
status: ListingStatus = ListingStatus.ACTIVE
|
||||
created_at: str = ""
|
||||
|
||||
# Fixed price fields
|
||||
price: int = 0
|
||||
|
||||
# Auction fields
|
||||
starting_bid: int = 0
|
||||
current_bid: int = 0
|
||||
buyout_price: int = 0
|
||||
bids: List[Bid] = field(default_factory=list)
|
||||
auction_end: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamps if not provided."""
|
||||
if not self.created_at:
|
||||
self.created_at = datetime.utcnow().isoformat()
|
||||
|
||||
def is_auction(self) -> bool:
|
||||
"""Check if this is an auction listing."""
|
||||
return self.listing_type == ListingType.AUCTION
|
||||
|
||||
def is_fixed_price(self) -> bool:
|
||||
"""Check if this is a fixed-price listing."""
|
||||
return self.listing_type == ListingType.FIXED_PRICE
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if listing is active."""
|
||||
return self.status == ListingStatus.ACTIVE
|
||||
|
||||
def has_ended(self) -> bool:
|
||||
"""Check if auction has ended (for auction listings)."""
|
||||
if not self.is_auction() or not self.auction_end:
|
||||
return False
|
||||
|
||||
end_time = datetime.fromisoformat(self.auction_end)
|
||||
return datetime.utcnow() >= end_time
|
||||
|
||||
def can_bid(self, bid_amount: int) -> bool:
|
||||
"""
|
||||
Check if a bid amount is valid.
|
||||
|
||||
Args:
|
||||
bid_amount: Proposed bid amount
|
||||
|
||||
Returns:
|
||||
True if bid is valid, False otherwise
|
||||
"""
|
||||
if not self.is_auction() or not self.is_active():
|
||||
return False
|
||||
|
||||
if self.has_ended():
|
||||
return False
|
||||
|
||||
# First bid must meet starting bid
|
||||
if not self.bids and bid_amount < self.starting_bid:
|
||||
return False
|
||||
|
||||
# Subsequent bids must exceed current bid
|
||||
if self.bids and bid_amount <= self.current_bid:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def place_bid(self, bidder_id: str, bidder_name: str, amount: int) -> bool:
|
||||
"""
|
||||
Place a bid on this auction.
|
||||
|
||||
Args:
|
||||
bidder_id: User ID of bidder
|
||||
bidder_name: Character name of bidder
|
||||
amount: Bid amount
|
||||
|
||||
Returns:
|
||||
True if bid was accepted, False otherwise
|
||||
"""
|
||||
if not self.can_bid(amount):
|
||||
return False
|
||||
|
||||
bid = Bid(
|
||||
bidder_id=bidder_id,
|
||||
bidder_name=bidder_name,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
self.bids.append(bid)
|
||||
self.current_bid = amount
|
||||
return True
|
||||
|
||||
def buyout(self) -> bool:
|
||||
"""
|
||||
Attempt to buy out the auction immediately.
|
||||
|
||||
Returns:
|
||||
True if buyout is available and successful, False otherwise
|
||||
"""
|
||||
if not self.is_auction() or not self.buyout_price:
|
||||
return False
|
||||
|
||||
if not self.is_active() or self.has_ended():
|
||||
return False
|
||||
|
||||
self.current_bid = self.buyout_price
|
||||
self.status = ListingStatus.SOLD
|
||||
return True
|
||||
|
||||
def get_winning_bidder(self) -> Optional[Bid]:
|
||||
"""
|
||||
Get the current winning bid.
|
||||
|
||||
Returns:
|
||||
Winning Bid or None if no bids
|
||||
"""
|
||||
if not self.bids:
|
||||
return None
|
||||
|
||||
# Bids are added chronologically, last one is highest
|
||||
return self.bids[-1]
|
||||
|
||||
def cancel_listing(self) -> bool:
|
||||
"""
|
||||
Cancel this listing (seller action).
|
||||
|
||||
Returns:
|
||||
True if successfully cancelled, False if cannot be cancelled
|
||||
"""
|
||||
if not self.is_active():
|
||||
return False
|
||||
|
||||
# Cannot cancel auction with bids
|
||||
if self.is_auction() and self.bids:
|
||||
return False
|
||||
|
||||
self.status = ListingStatus.REMOVED
|
||||
return True
|
||||
|
||||
def complete_sale(self) -> None:
|
||||
"""Mark listing as sold."""
|
||||
self.status = ListingStatus.SOLD
|
||||
|
||||
def expire_listing(self) -> None:
|
||||
"""Mark listing as expired."""
|
||||
self.status = ListingStatus.EXPIRED
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize listing to dictionary."""
|
||||
return {
|
||||
"listing_id": self.listing_id,
|
||||
"seller_id": self.seller_id,
|
||||
"character_id": self.character_id,
|
||||
"item_data": self.item_data.to_dict(),
|
||||
"listing_type": self.listing_type.value,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at,
|
||||
"price": self.price,
|
||||
"starting_bid": self.starting_bid,
|
||||
"current_bid": self.current_bid,
|
||||
"buyout_price": self.buyout_price,
|
||||
"bids": [bid.to_dict() for bid in self.bids],
|
||||
"auction_end": self.auction_end,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'MarketplaceListing':
|
||||
"""Deserialize listing from dictionary."""
|
||||
item_data = Item.from_dict(data["item_data"])
|
||||
listing_type = ListingType(data["listing_type"])
|
||||
status = ListingStatus(data.get("status", "active"))
|
||||
bids = [Bid.from_dict(b) for b in data.get("bids", [])]
|
||||
|
||||
return cls(
|
||||
listing_id=data["listing_id"],
|
||||
seller_id=data["seller_id"],
|
||||
character_id=data["character_id"],
|
||||
item_data=item_data,
|
||||
listing_type=listing_type,
|
||||
status=status,
|
||||
created_at=data.get("created_at", ""),
|
||||
price=data.get("price", 0),
|
||||
starting_bid=data.get("starting_bid", 0),
|
||||
current_bid=data.get("current_bid", 0),
|
||||
buyout_price=data.get("buyout_price", 0),
|
||||
bids=bids,
|
||||
auction_end=data.get("auction_end", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transaction:
|
||||
"""
|
||||
Record of a completed transaction.
|
||||
|
||||
Tracks all sales for auditing and analytics.
|
||||
|
||||
Attributes:
|
||||
transaction_id: Unique identifier
|
||||
buyer_id: User ID of buyer
|
||||
seller_id: User ID of seller
|
||||
listing_id: Marketplace listing ID (if from marketplace)
|
||||
item_data: Item that was sold
|
||||
price: Final sale price in gold
|
||||
timestamp: ISO timestamp of transaction
|
||||
transaction_type: "marketplace_sale", "shop_purchase", etc.
|
||||
"""
|
||||
|
||||
transaction_id: str
|
||||
buyer_id: str
|
||||
seller_id: str
|
||||
item_data: Item
|
||||
price: int
|
||||
transaction_type: str
|
||||
listing_id: str = ""
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize transaction to dictionary."""
|
||||
return {
|
||||
"transaction_id": self.transaction_id,
|
||||
"buyer_id": self.buyer_id,
|
||||
"seller_id": self.seller_id,
|
||||
"listing_id": self.listing_id,
|
||||
"item_data": self.item_data.to_dict(),
|
||||
"price": self.price,
|
||||
"timestamp": self.timestamp,
|
||||
"transaction_type": self.transaction_type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
|
||||
"""Deserialize transaction from dictionary."""
|
||||
item_data = Item.from_dict(data["item_data"])
|
||||
|
||||
return cls(
|
||||
transaction_id=data["transaction_id"],
|
||||
buyer_id=data["buyer_id"],
|
||||
seller_id=data["seller_id"],
|
||||
listing_id=data.get("listing_id", ""),
|
||||
item_data=item_data,
|
||||
price=data["price"],
|
||||
timestamp=data.get("timestamp", ""),
|
||||
transaction_type=data["transaction_type"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopItem:
|
||||
"""
|
||||
Item sold by NPC shops.
|
||||
|
||||
Attributes:
|
||||
item_id: Item identifier
|
||||
item: Item details
|
||||
stock: Available quantity (-1 = unlimited)
|
||||
price: Fixed gold price
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
item: Item
|
||||
stock: int = -1 # -1 = unlimited
|
||||
price: int = 0
|
||||
|
||||
def is_in_stock(self) -> bool:
|
||||
"""Check if item is available for purchase."""
|
||||
return self.stock != 0
|
||||
|
||||
def purchase(self, quantity: int = 1) -> bool:
|
||||
"""
|
||||
Attempt to purchase from stock.
|
||||
|
||||
Args:
|
||||
quantity: Number of items to purchase
|
||||
|
||||
Returns:
|
||||
True if purchase successful, False if insufficient stock
|
||||
"""
|
||||
if self.stock == -1: # Unlimited stock
|
||||
return True
|
||||
|
||||
if self.stock < quantity:
|
||||
return False
|
||||
|
||||
self.stock -= quantity
|
||||
return True
|
||||
|
||||
def restock(self, quantity: int) -> None:
|
||||
"""
|
||||
Add stock to this shop item.
|
||||
|
||||
Args:
|
||||
quantity: Amount to add to stock
|
||||
"""
|
||||
if self.stock == -1: # Unlimited, no need to restock
|
||||
return
|
||||
|
||||
self.stock += quantity
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize shop item to dictionary."""
|
||||
return {
|
||||
"item_id": self.item_id,
|
||||
"item": self.item.to_dict(),
|
||||
"stock": self.stock,
|
||||
"price": self.price,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ShopItem':
|
||||
"""Deserialize shop item from dictionary."""
|
||||
item = Item.from_dict(data["item"])
|
||||
|
||||
return cls(
|
||||
item_id=data["item_id"],
|
||||
item=item,
|
||||
stock=data.get("stock", -1),
|
||||
price=data.get("price", 0),
|
||||
)
|
||||
477
api/app/models/npc.py
Normal file
477
api/app/models/npc.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
NPC data models for persistent non-player characters.
|
||||
|
||||
This module defines NPC and related dataclasses that represent structured
|
||||
NPC definitions loaded from YAML files. NPCs have rich personality, knowledge,
|
||||
and interaction data that the AI uses for dialogue generation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCPersonality:
|
||||
"""
|
||||
NPC personality definition for AI dialogue generation.
|
||||
|
||||
Provides the AI with guidance on how to roleplay the NPC's character,
|
||||
including their general traits, speaking patterns, and distinctive behaviors.
|
||||
|
||||
Attributes:
|
||||
traits: List of personality descriptors (e.g., "gruff", "kind", "suspicious")
|
||||
speech_style: Description of how the NPC speaks (accent, vocabulary, patterns)
|
||||
quirks: List of distinctive behaviors or habits
|
||||
"""
|
||||
|
||||
traits: List[str]
|
||||
speech_style: str
|
||||
quirks: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize personality to dictionary."""
|
||||
return {
|
||||
"traits": self.traits,
|
||||
"speech_style": self.speech_style,
|
||||
"quirks": self.quirks,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCPersonality':
|
||||
"""Deserialize personality from dictionary."""
|
||||
return cls(
|
||||
traits=data.get("traits", []),
|
||||
speech_style=data.get("speech_style", ""),
|
||||
quirks=data.get("quirks", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCAppearance:
|
||||
"""
|
||||
NPC physical description.
|
||||
|
||||
Provides visual context for AI narration and player information.
|
||||
|
||||
Attributes:
|
||||
brief: Short one-line description for lists and quick reference
|
||||
detailed: Optional longer description for detailed encounters
|
||||
"""
|
||||
|
||||
brief: str
|
||||
detailed: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize appearance to dictionary."""
|
||||
return {
|
||||
"brief": self.brief,
|
||||
"detailed": self.detailed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCAppearance':
|
||||
"""Deserialize appearance from dictionary."""
|
||||
if isinstance(data, str):
|
||||
# Handle simple string format
|
||||
return cls(brief=data)
|
||||
return cls(
|
||||
brief=data.get("brief", ""),
|
||||
detailed=data.get("detailed"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCKnowledgeCondition:
|
||||
"""
|
||||
Condition for NPC to reveal secret knowledge.
|
||||
|
||||
Defines when and how an NPC will share information they normally keep hidden.
|
||||
Conditions are evaluated against the character's interaction state.
|
||||
|
||||
Attributes:
|
||||
condition: Expression describing what triggers the reveal
|
||||
(e.g., "interaction_count >= 3", "relationship_level >= 75")
|
||||
reveals: The information that gets revealed when condition is met
|
||||
"""
|
||||
|
||||
condition: str
|
||||
reveals: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize condition to dictionary."""
|
||||
return {
|
||||
"condition": self.condition,
|
||||
"reveals": self.reveals,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledgeCondition':
|
||||
"""Deserialize condition from dictionary."""
|
||||
return cls(
|
||||
condition=data.get("condition", ""),
|
||||
reveals=data.get("reveals", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCKnowledge:
|
||||
"""
|
||||
Knowledge an NPC possesses - public and secret.
|
||||
|
||||
Organizes what information an NPC knows and under what circumstances
|
||||
they will share it with players.
|
||||
|
||||
Attributes:
|
||||
public: Knowledge the NPC will freely share with anyone
|
||||
secret: Knowledge the NPC keeps hidden (for AI reference only)
|
||||
will_share_if: Conditional reveals based on character interaction state
|
||||
"""
|
||||
|
||||
public: List[str] = field(default_factory=list)
|
||||
secret: List[str] = field(default_factory=list)
|
||||
will_share_if: List[NPCKnowledgeCondition] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize knowledge to dictionary."""
|
||||
return {
|
||||
"public": self.public,
|
||||
"secret": self.secret,
|
||||
"will_share_if": [c.to_dict() for c in self.will_share_if],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledge':
|
||||
"""Deserialize knowledge from dictionary."""
|
||||
conditions = [
|
||||
NPCKnowledgeCondition.from_dict(c)
|
||||
for c in data.get("will_share_if", [])
|
||||
]
|
||||
return cls(
|
||||
public=data.get("public", []),
|
||||
secret=data.get("secret", []),
|
||||
will_share_if=conditions,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCRelationship:
|
||||
"""
|
||||
NPC's relationship with another NPC.
|
||||
|
||||
Defines how this NPC feels about other NPCs in the world,
|
||||
providing context for dialogue and interactions.
|
||||
|
||||
Attributes:
|
||||
npc_id: The other NPC's identifier
|
||||
attitude: How this NPC feels (e.g., "friendly", "distrustful", "romantic")
|
||||
reason: Optional explanation for the attitude
|
||||
"""
|
||||
|
||||
npc_id: str
|
||||
attitude: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize relationship to dictionary."""
|
||||
return {
|
||||
"npc_id": self.npc_id,
|
||||
"attitude": self.attitude,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCRelationship':
|
||||
"""Deserialize relationship from dictionary."""
|
||||
return cls(
|
||||
npc_id=data["npc_id"],
|
||||
attitude=data["attitude"],
|
||||
reason=data.get("reason"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCInventoryItem:
|
||||
"""
|
||||
Item an NPC has for sale.
|
||||
|
||||
Defines items available for purchase from merchant NPCs.
|
||||
|
||||
Attributes:
|
||||
item_id: Reference to item definition
|
||||
price: Cost in gold
|
||||
quantity: Stock count (None = unlimited)
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
price: int
|
||||
quantity: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize inventory item to dictionary."""
|
||||
return {
|
||||
"item_id": self.item_id,
|
||||
"price": self.price,
|
||||
"quantity": self.quantity,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCInventoryItem':
|
||||
"""Deserialize inventory item from dictionary."""
|
||||
# Handle shorthand format: { item: "ale", price: 2 }
|
||||
item_id = data.get("item_id") or data.get("item", "")
|
||||
return cls(
|
||||
item_id=item_id,
|
||||
price=data.get("price", 0),
|
||||
quantity=data.get("quantity"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCDialogueHooks:
|
||||
"""
|
||||
Pre-defined dialogue snippets for AI context.
|
||||
|
||||
Provides example phrases the AI can use or adapt to maintain
|
||||
consistent NPC voice across conversations.
|
||||
|
||||
Attributes:
|
||||
greeting: What NPC says when first addressed
|
||||
farewell: What NPC says when conversation ends
|
||||
busy: What NPC says when occupied or dismissive
|
||||
quest_complete: What NPC says when player completes their quest
|
||||
"""
|
||||
|
||||
greeting: Optional[str] = None
|
||||
farewell: Optional[str] = None
|
||||
busy: Optional[str] = None
|
||||
quest_complete: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize dialogue hooks to dictionary."""
|
||||
return {
|
||||
"greeting": self.greeting,
|
||||
"farewell": self.farewell,
|
||||
"busy": self.busy,
|
||||
"quest_complete": self.quest_complete,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCDialogueHooks':
|
||||
"""Deserialize dialogue hooks from dictionary."""
|
||||
return cls(
|
||||
greeting=data.get("greeting"),
|
||||
farewell=data.get("farewell"),
|
||||
busy=data.get("busy"),
|
||||
quest_complete=data.get("quest_complete"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPC:
|
||||
"""
|
||||
Persistent NPC definition.
|
||||
|
||||
NPCs are fixed to locations and have rich personality, knowledge,
|
||||
and interaction data used by the AI for dialogue generation.
|
||||
|
||||
Attributes:
|
||||
npc_id: Unique identifier (e.g., "npc_grom_001")
|
||||
name: Display name (e.g., "Grom Ironbeard")
|
||||
role: NPC's job/title (e.g., "bartender", "blacksmith")
|
||||
location_id: ID of location where this NPC resides
|
||||
personality: Personality traits and speech patterns
|
||||
appearance: Physical description
|
||||
knowledge: What the NPC knows (public and secret)
|
||||
relationships: How NPC feels about other NPCs
|
||||
inventory_for_sale: Items NPC sells (if merchant)
|
||||
dialogue_hooks: Pre-defined dialogue snippets
|
||||
quest_giver_for: Quest IDs this NPC can give
|
||||
reveals_locations: Location IDs this NPC can unlock through conversation
|
||||
tags: Metadata tags for filtering (e.g., "merchant", "quest_giver")
|
||||
"""
|
||||
|
||||
npc_id: str
|
||||
name: str
|
||||
role: str
|
||||
location_id: str
|
||||
personality: NPCPersonality
|
||||
appearance: NPCAppearance
|
||||
knowledge: Optional[NPCKnowledge] = None
|
||||
relationships: List[NPCRelationship] = field(default_factory=list)
|
||||
inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list)
|
||||
dialogue_hooks: Optional[NPCDialogueHooks] = None
|
||||
quest_giver_for: List[str] = field(default_factory=list)
|
||||
reveals_locations: List[str] = field(default_factory=list)
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize NPC to dictionary for JSON responses.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all NPC data
|
||||
"""
|
||||
return {
|
||||
"npc_id": self.npc_id,
|
||||
"name": self.name,
|
||||
"role": self.role,
|
||||
"location_id": self.location_id,
|
||||
"personality": self.personality.to_dict(),
|
||||
"appearance": self.appearance.to_dict(),
|
||||
"knowledge": self.knowledge.to_dict() if self.knowledge else None,
|
||||
"relationships": [r.to_dict() for r in self.relationships],
|
||||
"inventory_for_sale": [i.to_dict() for i in self.inventory_for_sale],
|
||||
"dialogue_hooks": self.dialogue_hooks.to_dict() if self.dialogue_hooks else None,
|
||||
"quest_giver_for": self.quest_giver_for,
|
||||
"reveals_locations": self.reveals_locations,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
def to_story_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize NPC for AI dialogue context.
|
||||
|
||||
Returns a trimmed version focused on roleplay-relevant data
|
||||
to reduce token usage in AI prompts.
|
||||
|
||||
Returns:
|
||||
Dictionary containing story-relevant NPC data
|
||||
"""
|
||||
result = {
|
||||
"name": self.name,
|
||||
"role": self.role,
|
||||
"personality": {
|
||||
"traits": self.personality.traits,
|
||||
"speech_style": self.personality.speech_style,
|
||||
"quirks": self.personality.quirks,
|
||||
},
|
||||
"appearance": self.appearance.brief,
|
||||
}
|
||||
|
||||
# Include dialogue hooks if available
|
||||
if self.dialogue_hooks:
|
||||
result["dialogue_hooks"] = self.dialogue_hooks.to_dict()
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPC':
|
||||
"""
|
||||
Deserialize NPC from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing NPC data (from YAML or JSON)
|
||||
|
||||
Returns:
|
||||
NPC instance
|
||||
"""
|
||||
# Parse personality
|
||||
personality_data = data.get("personality", {})
|
||||
personality = NPCPersonality.from_dict(personality_data)
|
||||
|
||||
# Parse appearance
|
||||
appearance_data = data.get("appearance", {"brief": ""})
|
||||
appearance = NPCAppearance.from_dict(appearance_data)
|
||||
|
||||
# Parse knowledge (optional)
|
||||
knowledge = None
|
||||
if data.get("knowledge"):
|
||||
knowledge = NPCKnowledge.from_dict(data["knowledge"])
|
||||
|
||||
# Parse relationships
|
||||
relationships = [
|
||||
NPCRelationship.from_dict(r)
|
||||
for r in data.get("relationships", [])
|
||||
]
|
||||
|
||||
# Parse inventory
|
||||
inventory = [
|
||||
NPCInventoryItem.from_dict(i)
|
||||
for i in data.get("inventory_for_sale", [])
|
||||
]
|
||||
|
||||
# Parse dialogue hooks (optional)
|
||||
dialogue_hooks = None
|
||||
if data.get("dialogue_hooks"):
|
||||
dialogue_hooks = NPCDialogueHooks.from_dict(data["dialogue_hooks"])
|
||||
|
||||
return cls(
|
||||
npc_id=data["npc_id"],
|
||||
name=data["name"],
|
||||
role=data["role"],
|
||||
location_id=data["location_id"],
|
||||
personality=personality,
|
||||
appearance=appearance,
|
||||
knowledge=knowledge,
|
||||
relationships=relationships,
|
||||
inventory_for_sale=inventory,
|
||||
dialogue_hooks=dialogue_hooks,
|
||||
quest_giver_for=data.get("quest_giver_for", []),
|
||||
reveals_locations=data.get("reveals_locations", []),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the NPC."""
|
||||
return f"NPC({self.npc_id}, {self.name}, {self.role})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCInteractionState:
|
||||
"""
|
||||
Tracks a character's interaction history with an NPC.
|
||||
|
||||
Stored on the Character record to persist relationship data
|
||||
across multiple game sessions.
|
||||
|
||||
Attributes:
|
||||
npc_id: The NPC this state tracks
|
||||
first_met: ISO timestamp of first interaction
|
||||
last_interaction: ISO timestamp of most recent interaction
|
||||
interaction_count: Total number of conversations
|
||||
revealed_secrets: Indices of secrets that have been revealed
|
||||
relationship_level: 0-100 scale (50 is neutral)
|
||||
custom_flags: Arbitrary flags for special conditions
|
||||
(e.g., {"helped_with_rats": true})
|
||||
"""
|
||||
|
||||
npc_id: str
|
||||
first_met: str
|
||||
last_interaction: str
|
||||
interaction_count: int = 0
|
||||
revealed_secrets: List[int] = field(default_factory=list)
|
||||
relationship_level: int = 50
|
||||
custom_flags: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize interaction state to dictionary."""
|
||||
return {
|
||||
"npc_id": self.npc_id,
|
||||
"first_met": self.first_met,
|
||||
"last_interaction": self.last_interaction,
|
||||
"interaction_count": self.interaction_count,
|
||||
"revealed_secrets": self.revealed_secrets,
|
||||
"relationship_level": self.relationship_level,
|
||||
"custom_flags": self.custom_flags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'NPCInteractionState':
|
||||
"""Deserialize interaction state from dictionary."""
|
||||
return cls(
|
||||
npc_id=data["npc_id"],
|
||||
first_met=data["first_met"],
|
||||
last_interaction=data["last_interaction"],
|
||||
interaction_count=data.get("interaction_count", 0),
|
||||
revealed_secrets=data.get("revealed_secrets", []),
|
||||
relationship_level=data.get("relationship_level", 50),
|
||||
custom_flags=data.get("custom_flags", {}),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the interaction state."""
|
||||
return (
|
||||
f"NPCInteractionState({self.npc_id}, "
|
||||
f"interactions={self.interaction_count}, "
|
||||
f"relationship={self.relationship_level})"
|
||||
)
|
||||
148
api/app/models/origins.py
Normal file
148
api/app/models/origins.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Origin data models - character backstory and starting conditions.
|
||||
|
||||
Origins are saved to the character and referenced by the AI DM throughout
|
||||
the game to create personalized narrative experiences, quest hooks, and
|
||||
story-driven interactions.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartingLocation:
|
||||
"""
|
||||
Represents where a character begins their journey.
|
||||
|
||||
Attributes:
|
||||
id: Unique location identifier
|
||||
name: Display name of the location
|
||||
region: Larger geographical area this location belongs to
|
||||
description: Brief description of the location
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
description: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"region": self.region,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'StartingLocation':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
region=data["region"],
|
||||
description=data["description"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartingBonus:
|
||||
"""
|
||||
Represents mechanical benefits from an origin choice.
|
||||
|
||||
Attributes:
|
||||
trait: Name of the trait/ability granted
|
||||
description: What the trait represents narratively
|
||||
effect: Mechanical game effect (stat bonuses, special abilities, etc.)
|
||||
"""
|
||||
trait: str
|
||||
description: str
|
||||
effect: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"trait": self.trait,
|
||||
"description": self.description,
|
||||
"effect": self.effect,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'StartingBonus':
|
||||
"""Deserialize from dictionary."""
|
||||
return cls(
|
||||
trait=data["trait"],
|
||||
description=data["description"],
|
||||
effect=data["effect"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Origin:
|
||||
"""
|
||||
Represents a character's backstory and starting conditions.
|
||||
|
||||
Origins are permanent character attributes that the AI DM uses to
|
||||
create personalized narratives, generate relevant quest hooks, and
|
||||
tailor NPC interactions throughout the game.
|
||||
|
||||
Attributes:
|
||||
id: Unique origin identifier (e.g., "soul_revenant")
|
||||
name: Display name (e.g., "Soul Revenant")
|
||||
description: Full backstory text that explains the origin
|
||||
starting_location: Where the character begins their journey
|
||||
narrative_hooks: List of story elements the AI can reference
|
||||
starting_bonus: Mechanical benefits from this origin
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
starting_location: StartingLocation
|
||||
narrative_hooks: List[str] = field(default_factory=list)
|
||||
starting_bonus: StartingBonus = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize origin to dictionary for JSON storage.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all origin data
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"starting_location": self.starting_location.to_dict(),
|
||||
"narrative_hooks": self.narrative_hooks,
|
||||
"starting_bonus": self.starting_bonus.to_dict() if self.starting_bonus else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Origin':
|
||||
"""
|
||||
Deserialize origin from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing origin data
|
||||
|
||||
Returns:
|
||||
Origin instance
|
||||
"""
|
||||
starting_location = StartingLocation.from_dict(data["starting_location"])
|
||||
starting_bonus = None
|
||||
if data.get("starting_bonus"):
|
||||
starting_bonus = StartingBonus.from_dict(data["starting_bonus"])
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
starting_location=starting_location,
|
||||
narrative_hooks=data.get("narrative_hooks", []),
|
||||
starting_bonus=starting_bonus,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the origin."""
|
||||
return f"Origin({self.name}, starts at {self.starting_location.name})"
|
||||
411
api/app/models/session.py
Normal file
411
api/app/models/session.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Game session data models.
|
||||
|
||||
This module defines the session-related dataclasses including SessionConfig,
|
||||
GameState, and GameSession which manage multiplayer party sessions.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models.combat import CombatEncounter
|
||||
from app.models.enums import SessionStatus, SessionType
|
||||
from app.models.action_prompt import LocationType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionConfig:
|
||||
"""
|
||||
Configuration settings for a game session.
|
||||
|
||||
Attributes:
|
||||
min_players: Minimum players required (session ends if below this)
|
||||
timeout_minutes: Inactivity timeout in minutes
|
||||
auto_save_interval: Turns between automatic saves
|
||||
"""
|
||||
|
||||
min_players: int = 1
|
||||
timeout_minutes: int = 30
|
||||
auto_save_interval: int = 5
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize configuration to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SessionConfig':
|
||||
"""Deserialize configuration from dictionary."""
|
||||
return cls(
|
||||
min_players=data.get("min_players", 1),
|
||||
timeout_minutes=data.get("timeout_minutes", 30),
|
||||
auto_save_interval=data.get("auto_save_interval", 5),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""
|
||||
Current world/quest state for a game session.
|
||||
|
||||
Attributes:
|
||||
current_location: Current location name/ID
|
||||
location_type: Type of current location (town, tavern, wilderness, etc.)
|
||||
discovered_locations: All location IDs the party has visited
|
||||
active_quests: Quest IDs currently in progress
|
||||
world_events: Server-wide events affecting this session
|
||||
"""
|
||||
|
||||
current_location: str = "crossville_village"
|
||||
location_type: LocationType = LocationType.TOWN
|
||||
discovered_locations: List[str] = field(default_factory=list)
|
||||
active_quests: List[str] = field(default_factory=list)
|
||||
world_events: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize game state to dictionary."""
|
||||
return {
|
||||
"current_location": self.current_location,
|
||||
"location_type": self.location_type.value,
|
||||
"discovered_locations": self.discovered_locations,
|
||||
"active_quests": self.active_quests,
|
||||
"world_events": self.world_events,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'GameState':
|
||||
"""Deserialize game state from dictionary."""
|
||||
# Handle location_type as either string or enum
|
||||
location_type_value = data.get("location_type", "town")
|
||||
if isinstance(location_type_value, str):
|
||||
location_type = LocationType(location_type_value)
|
||||
else:
|
||||
location_type = location_type_value
|
||||
|
||||
return cls(
|
||||
current_location=data.get("current_location", "crossville_village"),
|
||||
location_type=location_type,
|
||||
discovered_locations=data.get("discovered_locations", []),
|
||||
active_quests=data.get("active_quests", []),
|
||||
world_events=data.get("world_events", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationEntry:
|
||||
"""
|
||||
Single entry in the conversation history.
|
||||
|
||||
Attributes:
|
||||
turn: Turn number
|
||||
character_id: Acting character's ID
|
||||
character_name: Acting character's name
|
||||
action: Player's action/input text
|
||||
dm_response: AI Dungeon Master's response
|
||||
timestamp: ISO timestamp of when entry was created
|
||||
combat_log: Combat actions if any occurred this turn
|
||||
quest_offered: Quest offering info if a quest was offered this turn
|
||||
"""
|
||||
|
||||
turn: int
|
||||
character_id: str
|
||||
character_name: str
|
||||
action: str
|
||||
dm_response: str
|
||||
timestamp: str = ""
|
||||
combat_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
quest_offered: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamp if not provided."""
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize conversation entry to dictionary."""
|
||||
result = {
|
||||
"turn": self.turn,
|
||||
"character_id": self.character_id,
|
||||
"character_name": self.character_name,
|
||||
"action": self.action,
|
||||
"dm_response": self.dm_response,
|
||||
"timestamp": self.timestamp,
|
||||
"combat_log": self.combat_log,
|
||||
}
|
||||
if self.quest_offered:
|
||||
result["quest_offered"] = self.quest_offered
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationEntry':
|
||||
"""Deserialize conversation entry from dictionary."""
|
||||
return cls(
|
||||
turn=data["turn"],
|
||||
character_id=data.get("character_id", ""),
|
||||
character_name=data.get("character_name", ""),
|
||||
action=data["action"],
|
||||
dm_response=data["dm_response"],
|
||||
timestamp=data.get("timestamp", ""),
|
||||
combat_log=data.get("combat_log", []),
|
||||
quest_offered=data.get("quest_offered"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSession:
|
||||
"""
|
||||
Represents a game session (solo or multiplayer).
|
||||
|
||||
A session can have one or more players (party) and tracks the entire
|
||||
game state including conversation history, combat encounters, and
|
||||
turn order.
|
||||
|
||||
Attributes:
|
||||
session_id: Unique identifier
|
||||
session_type: Type of session (solo or multiplayer)
|
||||
solo_character_id: Character ID for single-player sessions (None for multiplayer)
|
||||
user_id: Owner of the session
|
||||
party_member_ids: Character IDs in this party (multiplayer only)
|
||||
config: Session configuration settings
|
||||
combat_encounter: Current combat (None if not in combat)
|
||||
conversation_history: Turn-by-turn log of actions and DM responses
|
||||
game_state: Current world/quest state
|
||||
turn_order: Character turn order
|
||||
current_turn: Index in turn_order for current turn
|
||||
turn_number: Global turn counter
|
||||
created_at: ISO timestamp of session creation
|
||||
last_activity: ISO timestamp of last action
|
||||
status: Current session status (active, completed, timeout)
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
session_type: SessionType = SessionType.SOLO
|
||||
solo_character_id: Optional[str] = None
|
||||
user_id: str = ""
|
||||
party_member_ids: List[str] = field(default_factory=list)
|
||||
config: SessionConfig = field(default_factory=SessionConfig)
|
||||
combat_encounter: Optional[CombatEncounter] = None
|
||||
conversation_history: List[ConversationEntry] = field(default_factory=list)
|
||||
game_state: GameState = field(default_factory=GameState)
|
||||
turn_order: List[str] = field(default_factory=list)
|
||||
current_turn: int = 0
|
||||
turn_number: int = 0
|
||||
created_at: str = ""
|
||||
last_activity: str = ""
|
||||
status: SessionStatus = SessionStatus.ACTIVE
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize timestamps if not provided."""
|
||||
if not self.created_at:
|
||||
self.created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if not self.last_activity:
|
||||
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def is_in_combat(self) -> bool:
|
||||
"""Check if session is currently in combat."""
|
||||
return self.combat_encounter is not None
|
||||
|
||||
def start_combat(self, encounter: CombatEncounter) -> None:
|
||||
"""
|
||||
Start a combat encounter.
|
||||
|
||||
Args:
|
||||
encounter: The combat encounter to begin
|
||||
"""
|
||||
self.combat_encounter = encounter
|
||||
self.update_activity()
|
||||
|
||||
def end_combat(self) -> None:
|
||||
"""End the current combat encounter."""
|
||||
self.combat_encounter = None
|
||||
self.update_activity()
|
||||
|
||||
def advance_turn(self) -> str:
|
||||
"""
|
||||
Advance to the next player's turn.
|
||||
|
||||
Returns:
|
||||
Character ID whose turn it now is
|
||||
"""
|
||||
if not self.turn_order:
|
||||
return ""
|
||||
|
||||
self.current_turn = (self.current_turn + 1) % len(self.turn_order)
|
||||
self.turn_number += 1
|
||||
self.update_activity()
|
||||
|
||||
return self.turn_order[self.current_turn]
|
||||
|
||||
def get_current_character_id(self) -> Optional[str]:
|
||||
"""Get the character ID whose turn it currently is."""
|
||||
if not self.turn_order:
|
||||
return None
|
||||
return self.turn_order[self.current_turn]
|
||||
|
||||
def add_conversation_entry(self, entry: ConversationEntry) -> None:
|
||||
"""
|
||||
Add an entry to the conversation history.
|
||||
|
||||
Args:
|
||||
entry: Conversation entry to add
|
||||
"""
|
||||
self.conversation_history.append(entry)
|
||||
self.update_activity()
|
||||
|
||||
def update_activity(self) -> None:
|
||||
"""Update the last activity timestamp to now."""
|
||||
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def add_party_member(self, character_id: str) -> None:
|
||||
"""
|
||||
Add a character to the party.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to add
|
||||
"""
|
||||
if character_id not in self.party_member_ids:
|
||||
self.party_member_ids.append(character_id)
|
||||
self.update_activity()
|
||||
|
||||
def remove_party_member(self, character_id: str) -> None:
|
||||
"""
|
||||
Remove a character from the party.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to remove
|
||||
"""
|
||||
if character_id in self.party_member_ids:
|
||||
self.party_member_ids.remove(character_id)
|
||||
# Also remove from turn order
|
||||
if character_id in self.turn_order:
|
||||
self.turn_order.remove(character_id)
|
||||
self.update_activity()
|
||||
|
||||
def check_timeout(self) -> bool:
|
||||
"""
|
||||
Check if session has timed out due to inactivity.
|
||||
|
||||
Returns:
|
||||
True if session should be marked as timed out
|
||||
"""
|
||||
if self.status != SessionStatus.ACTIVE:
|
||||
return False
|
||||
|
||||
# Calculate time since last activity
|
||||
last_activity_str = self.last_activity.replace("Z", "+00:00")
|
||||
last_activity_time = datetime.fromisoformat(last_activity_str)
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed_minutes = (now - last_activity_time).total_seconds() / 60
|
||||
|
||||
if elapsed_minutes >= self.config.timeout_minutes:
|
||||
self.status = SessionStatus.TIMEOUT
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def check_min_players(self) -> bool:
|
||||
"""
|
||||
Check if session still has minimum required players.
|
||||
|
||||
Returns:
|
||||
True if session should continue, False if it should end
|
||||
"""
|
||||
if len(self.party_member_ids) < self.config.min_players:
|
||||
if self.status == SessionStatus.ACTIVE:
|
||||
self.status = SessionStatus.COMPLETED
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_solo(self) -> bool:
|
||||
"""Check if this is a solo session."""
|
||||
return self.session_type == SessionType.SOLO
|
||||
|
||||
def get_character_id(self) -> Optional[str]:
|
||||
"""
|
||||
Get the primary character ID for the session.
|
||||
|
||||
For solo sessions, returns solo_character_id.
|
||||
For multiplayer, returns the current character in turn order.
|
||||
"""
|
||||
if self.is_solo():
|
||||
return self.solo_character_id
|
||||
return self.get_current_character_id()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize game session to dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"session_type": self.session_type.value,
|
||||
"solo_character_id": self.solo_character_id,
|
||||
"user_id": self.user_id,
|
||||
"party_member_ids": self.party_member_ids,
|
||||
"config": self.config.to_dict(),
|
||||
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
|
||||
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
|
||||
"game_state": self.game_state.to_dict(),
|
||||
"turn_order": self.turn_order,
|
||||
"current_turn": self.current_turn,
|
||||
"turn_number": self.turn_number,
|
||||
"created_at": self.created_at,
|
||||
"last_activity": self.last_activity,
|
||||
"status": self.status.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'GameSession':
|
||||
"""Deserialize game session from dictionary."""
|
||||
config = SessionConfig.from_dict(data.get("config", {}))
|
||||
game_state = GameState.from_dict(data.get("game_state", {}))
|
||||
conversation_history = [
|
||||
ConversationEntry.from_dict(entry)
|
||||
for entry in data.get("conversation_history", [])
|
||||
]
|
||||
|
||||
combat_encounter = None
|
||||
if data.get("combat_encounter"):
|
||||
combat_encounter = CombatEncounter.from_dict(data["combat_encounter"])
|
||||
|
||||
status = SessionStatus(data.get("status", "active"))
|
||||
|
||||
# Handle session_type as either string or enum
|
||||
session_type_value = data.get("session_type", "solo")
|
||||
if isinstance(session_type_value, str):
|
||||
session_type = SessionType(session_type_value)
|
||||
else:
|
||||
session_type = session_type_value
|
||||
|
||||
return cls(
|
||||
session_id=data["session_id"],
|
||||
session_type=session_type,
|
||||
solo_character_id=data.get("solo_character_id"),
|
||||
user_id=data.get("user_id", ""),
|
||||
party_member_ids=data.get("party_member_ids", []),
|
||||
config=config,
|
||||
combat_encounter=combat_encounter,
|
||||
conversation_history=conversation_history,
|
||||
game_state=game_state,
|
||||
turn_order=data.get("turn_order", []),
|
||||
current_turn=data.get("current_turn", 0),
|
||||
turn_number=data.get("turn_number", 0),
|
||||
created_at=data.get("created_at", ""),
|
||||
last_activity=data.get("last_activity", ""),
|
||||
status=status,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the session."""
|
||||
if self.is_solo():
|
||||
return (
|
||||
f"GameSession({self.session_id}, "
|
||||
f"type=solo, "
|
||||
f"char={self.solo_character_id}, "
|
||||
f"turn={self.turn_number}, "
|
||||
f"status={self.status.value})"
|
||||
)
|
||||
return (
|
||||
f"GameSession({self.session_id}, "
|
||||
f"type=multiplayer, "
|
||||
f"party={len(self.party_member_ids)}, "
|
||||
f"turn={self.turn_number}, "
|
||||
f"status={self.status.value})"
|
||||
)
|
||||
290
api/app/models/skills.py
Normal file
290
api/app/models/skills.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Skill tree and character class system.
|
||||
|
||||
This module defines the progression system including skill nodes, skill trees,
|
||||
and player classes. Characters unlock skills by spending skill points earned
|
||||
through leveling.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.stats import Stats
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillNode:
|
||||
"""
|
||||
Represents a single skill in a skill tree.
|
||||
|
||||
Skills can provide passive bonuses, unlock active abilities, or grant
|
||||
access to new features (like equipment types).
|
||||
|
||||
Attributes:
|
||||
skill_id: Unique identifier
|
||||
name: Display name
|
||||
description: What this skill does
|
||||
tier: Skill tier (1-5, where 1 is basic and 5 is master)
|
||||
prerequisites: List of skill_ids that must be unlocked first
|
||||
effects: Dictionary of effects this skill provides
|
||||
Examples:
|
||||
- Passive bonuses: {"strength": 5, "defense": 10}
|
||||
- Ability unlocks: {"unlocks_ability": "shield_bash"}
|
||||
- Feature access: {"unlocks_equipment": "heavy_armor"}
|
||||
unlocked: Current unlock status (used during gameplay)
|
||||
"""
|
||||
|
||||
skill_id: str
|
||||
name: str
|
||||
description: str
|
||||
tier: int # 1-5
|
||||
prerequisites: List[str] = field(default_factory=list)
|
||||
effects: Dict[str, Any] = field(default_factory=dict)
|
||||
unlocked: bool = False
|
||||
|
||||
def has_prerequisites_met(self, unlocked_skills: List[str]) -> bool:
|
||||
"""
|
||||
Check if all prerequisites for this skill are met.
|
||||
|
||||
Args:
|
||||
unlocked_skills: List of skill_ids the character has unlocked
|
||||
|
||||
Returns:
|
||||
True if all prerequisites are met, False otherwise
|
||||
"""
|
||||
return all(prereq in unlocked_skills for prereq in self.prerequisites)
|
||||
|
||||
def get_stat_bonuses(self) -> Dict[str, int]:
|
||||
"""
|
||||
Extract stat bonuses from this skill's effects.
|
||||
|
||||
Returns:
|
||||
Dictionary of stat bonuses (e.g., {"strength": 5, "defense": 3})
|
||||
"""
|
||||
bonuses = {}
|
||||
for key, value in self.effects.items():
|
||||
# Look for stat names in effects
|
||||
if key in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]:
|
||||
bonuses[key] = value
|
||||
elif key == "defense" or key == "resistance" or key == "hit_points" or key == "mana_points":
|
||||
bonuses[key] = value
|
||||
return bonuses
|
||||
|
||||
def get_unlocked_abilities(self) -> List[str]:
|
||||
"""
|
||||
Extract ability IDs unlocked by this skill.
|
||||
|
||||
Returns:
|
||||
List of ability_ids this skill unlocks
|
||||
"""
|
||||
abilities = []
|
||||
if "unlocks_ability" in self.effects:
|
||||
ability = self.effects["unlocks_ability"]
|
||||
if isinstance(ability, list):
|
||||
abilities.extend(ability)
|
||||
else:
|
||||
abilities.append(ability)
|
||||
return abilities
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize skill node to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SkillNode':
|
||||
"""Deserialize skill node from dictionary."""
|
||||
return cls(
|
||||
skill_id=data["skill_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
tier=data["tier"],
|
||||
prerequisites=data.get("prerequisites", []),
|
||||
effects=data.get("effects", {}),
|
||||
unlocked=data.get("unlocked", False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillTree:
|
||||
"""
|
||||
Represents a complete skill tree for a character class.
|
||||
|
||||
Each class has 2+ skill trees representing different specializations.
|
||||
|
||||
Attributes:
|
||||
tree_id: Unique identifier
|
||||
name: Display name (e.g., "Shield Bearer", "Pyromancy")
|
||||
description: Theme and purpose of this tree
|
||||
nodes: All skill nodes in this tree (organized by tier)
|
||||
"""
|
||||
|
||||
tree_id: str
|
||||
name: str
|
||||
description: str
|
||||
nodes: List[SkillNode] = field(default_factory=list)
|
||||
|
||||
def can_unlock(self, skill_id: str, unlocked_skills: List[str]) -> bool:
|
||||
"""
|
||||
Check if a specific skill can be unlocked.
|
||||
|
||||
Validates:
|
||||
1. Skill exists in this tree
|
||||
2. Prerequisites are met
|
||||
3. Tier progression rules (must unlock tier N before tier N+1)
|
||||
|
||||
Args:
|
||||
skill_id: The skill to check
|
||||
unlocked_skills: Currently unlocked skill_ids
|
||||
|
||||
Returns:
|
||||
True if skill can be unlocked, False otherwise
|
||||
"""
|
||||
# Find the skill node
|
||||
skill_node = None
|
||||
for node in self.nodes:
|
||||
if node.skill_id == skill_id:
|
||||
skill_node = node
|
||||
break
|
||||
|
||||
if not skill_node:
|
||||
return False # Skill not in this tree
|
||||
|
||||
# Check if already unlocked
|
||||
if skill_id in unlocked_skills:
|
||||
return False
|
||||
|
||||
# Check prerequisites
|
||||
if not skill_node.has_prerequisites_met(unlocked_skills):
|
||||
return False
|
||||
|
||||
# Check tier progression
|
||||
# Must have at least one skill from previous tier unlocked
|
||||
# (except for tier 1 which is always available)
|
||||
if skill_node.tier > 1:
|
||||
has_previous_tier = False
|
||||
for node in self.nodes:
|
||||
if node.tier == skill_node.tier - 1 and node.skill_id in unlocked_skills:
|
||||
has_previous_tier = True
|
||||
break
|
||||
if not has_previous_tier:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_nodes_by_tier(self, tier: int) -> List[SkillNode]:
|
||||
"""
|
||||
Get all skill nodes for a specific tier.
|
||||
|
||||
Args:
|
||||
tier: Tier number (1-5)
|
||||
|
||||
Returns:
|
||||
List of SkillNodes at that tier
|
||||
"""
|
||||
return [node for node in self.nodes if node.tier == tier]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize skill tree to dictionary."""
|
||||
data = asdict(self)
|
||||
data["nodes"] = [node.to_dict() for node in self.nodes]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SkillTree':
|
||||
"""Deserialize skill tree from dictionary."""
|
||||
nodes = [SkillNode.from_dict(n) for n in data.get("nodes", [])]
|
||||
return cls(
|
||||
tree_id=data["tree_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerClass:
|
||||
"""
|
||||
Represents a character class (Vanguard, Assassin, Arcanist, etc.).
|
||||
|
||||
Each class has unique base stats, multiple skill trees, and starting equipment.
|
||||
|
||||
Attributes:
|
||||
class_id: Unique identifier
|
||||
name: Display name
|
||||
description: Class theme and playstyle
|
||||
base_stats: Starting stats for this class
|
||||
skill_trees: List of skill trees (2+ per class)
|
||||
starting_equipment: List of item_ids for initial equipment
|
||||
starting_abilities: List of ability_ids available from level 1
|
||||
"""
|
||||
|
||||
class_id: str
|
||||
name: str
|
||||
description: str
|
||||
base_stats: Stats
|
||||
skill_trees: List[SkillTree] = field(default_factory=list)
|
||||
starting_equipment: List[str] = field(default_factory=list)
|
||||
starting_abilities: List[str] = field(default_factory=list)
|
||||
|
||||
def get_skill_tree(self, tree_id: str) -> Optional[SkillTree]:
|
||||
"""
|
||||
Get a specific skill tree by ID.
|
||||
|
||||
Args:
|
||||
tree_id: Skill tree identifier
|
||||
|
||||
Returns:
|
||||
SkillTree instance or None if not found
|
||||
"""
|
||||
for tree in self.skill_trees:
|
||||
if tree.tree_id == tree_id:
|
||||
return tree
|
||||
return None
|
||||
|
||||
def get_all_skills(self) -> List[SkillNode]:
|
||||
"""
|
||||
Get all skill nodes from all trees.
|
||||
|
||||
Returns:
|
||||
Flat list of all SkillNodes across all trees
|
||||
"""
|
||||
all_skills = []
|
||||
for tree in self.skill_trees:
|
||||
all_skills.extend(tree.nodes)
|
||||
return all_skills
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize player class to dictionary."""
|
||||
return {
|
||||
"class_id": self.class_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
"skill_trees": [tree.to_dict() for tree in self.skill_trees],
|
||||
"starting_equipment": self.starting_equipment,
|
||||
"starting_abilities": self.starting_abilities,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PlayerClass':
|
||||
"""Deserialize player class from dictionary."""
|
||||
base_stats = Stats.from_dict(data["base_stats"])
|
||||
skill_trees = [SkillTree.from_dict(t) for t in data.get("skill_trees", [])]
|
||||
|
||||
return cls(
|
||||
class_id=data["class_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
base_stats=base_stats,
|
||||
skill_trees=skill_trees,
|
||||
starting_equipment=data.get("starting_equipment", []),
|
||||
starting_abilities=data.get("starting_abilities", []),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the player class."""
|
||||
return (
|
||||
f"PlayerClass({self.name}, "
|
||||
f"trees={len(self.skill_trees)}, "
|
||||
f"total_skills={len(self.get_all_skills())})"
|
||||
)
|
||||
140
api/app/models/stats.py
Normal file
140
api/app/models/stats.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Character statistics data model.
|
||||
|
||||
This module defines the Stats dataclass which represents a character's core
|
||||
attributes and provides computed properties for derived values like HP and MP.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""
|
||||
Character statistics representing core attributes.
|
||||
|
||||
Attributes:
|
||||
strength: Physical power, affects melee damage
|
||||
dexterity: Agility and precision, affects initiative and evasion
|
||||
constitution: Endurance and health, affects HP and defense
|
||||
intelligence: Magical power, affects spell damage and MP
|
||||
wisdom: Perception and insight, affects magical resistance
|
||||
charisma: Social influence, affects NPC interactions
|
||||
|
||||
Computed Properties:
|
||||
hit_points: Maximum HP = 10 + (constitution × 2)
|
||||
mana_points: Maximum MP = 10 + (intelligence × 2)
|
||||
defense: Physical defense = constitution // 2
|
||||
resistance: Magical resistance = wisdom // 2
|
||||
"""
|
||||
|
||||
strength: int = 10
|
||||
dexterity: int = 10
|
||||
constitution: int = 10
|
||||
intelligence: int = 10
|
||||
wisdom: int = 10
|
||||
charisma: int = 10
|
||||
|
||||
@property
|
||||
def hit_points(self) -> int:
|
||||
"""
|
||||
Calculate maximum hit points based on constitution.
|
||||
|
||||
Formula: 10 + (constitution × 2)
|
||||
|
||||
Returns:
|
||||
Maximum HP value
|
||||
"""
|
||||
return 10 + (self.constitution * 2)
|
||||
|
||||
@property
|
||||
def mana_points(self) -> int:
|
||||
"""
|
||||
Calculate maximum mana points based on intelligence.
|
||||
|
||||
Formula: 10 + (intelligence × 2)
|
||||
|
||||
Returns:
|
||||
Maximum MP value
|
||||
"""
|
||||
return 10 + (self.intelligence * 2)
|
||||
|
||||
@property
|
||||
def defense(self) -> int:
|
||||
"""
|
||||
Calculate physical defense from constitution.
|
||||
|
||||
Formula: constitution // 2
|
||||
|
||||
Returns:
|
||||
Physical defense value (damage reduction)
|
||||
"""
|
||||
return self.constitution // 2
|
||||
|
||||
@property
|
||||
def resistance(self) -> int:
|
||||
"""
|
||||
Calculate magical resistance from wisdom.
|
||||
|
||||
Formula: wisdom // 2
|
||||
|
||||
Returns:
|
||||
Magical resistance value (spell damage reduction)
|
||||
"""
|
||||
return self.wisdom // 2
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize stats to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all stat values
|
||||
"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Stats':
|
||||
"""
|
||||
Deserialize stats from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing stat values
|
||||
|
||||
Returns:
|
||||
Stats instance
|
||||
"""
|
||||
return cls(
|
||||
strength=data.get("strength", 10),
|
||||
dexterity=data.get("dexterity", 10),
|
||||
constitution=data.get("constitution", 10),
|
||||
intelligence=data.get("intelligence", 10),
|
||||
wisdom=data.get("wisdom", 10),
|
||||
charisma=data.get("charisma", 10),
|
||||
)
|
||||
|
||||
def copy(self) -> 'Stats':
|
||||
"""
|
||||
Create a deep copy of this Stats instance.
|
||||
|
||||
Returns:
|
||||
New Stats instance with same values
|
||||
"""
|
||||
return Stats(
|
||||
strength=self.strength,
|
||||
dexterity=self.dexterity,
|
||||
constitution=self.constitution,
|
||||
intelligence=self.intelligence,
|
||||
wisdom=self.wisdom,
|
||||
charisma=self.charisma,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation showing all stats and computed properties."""
|
||||
return (
|
||||
f"Stats(STR={self.strength}, DEX={self.dexterity}, "
|
||||
f"CON={self.constitution}, INT={self.intelligence}, "
|
||||
f"WIS={self.wisdom}, CHA={self.charisma}, "
|
||||
f"HP={self.hit_points}, MP={self.mana_points}, "
|
||||
f"DEF={self.defense}, RES={self.resistance})"
|
||||
)
|
||||
0
api/app/services/__init__.py
Normal file
0
api/app/services/__init__.py
Normal file
320
api/app/services/action_prompt_loader.py
Normal file
320
api/app/services/action_prompt_loader.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Action Prompt Loader Service
|
||||
|
||||
This module provides a service for loading and filtering action prompts from YAML.
|
||||
It implements a singleton pattern to cache loaded prompts in memory.
|
||||
|
||||
Usage:
|
||||
from app.services.action_prompt_loader import ActionPromptLoader
|
||||
|
||||
loader = ActionPromptLoader()
|
||||
loader.load_from_yaml("app/data/action_prompts.yaml")
|
||||
|
||||
# Get available actions for a user at a location
|
||||
actions = loader.get_available_actions(
|
||||
user_tier=UserTier.FREE,
|
||||
location_type=LocationType.TOWN
|
||||
)
|
||||
|
||||
# Get specific action
|
||||
action = loader.get_action_by_id("ask_locals")
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
from app.models.action_prompt import ActionPrompt, LocationType
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class ActionPromptLoaderError(Exception):
|
||||
"""Base exception for action prompt loader errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionPromptNotFoundError(ActionPromptLoaderError):
|
||||
"""Raised when a requested action prompt is not found."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionPromptLoader:
|
||||
"""
|
||||
Service for loading and filtering action prompts.
|
||||
|
||||
This class loads action prompts from YAML files and provides methods
|
||||
to filter them based on user tier and location type.
|
||||
|
||||
Uses singleton pattern to cache loaded prompts in memory.
|
||||
|
||||
Attributes:
|
||||
_prompts: Dictionary of loaded action prompts keyed by prompt_id
|
||||
_loaded: Flag indicating if prompts have been loaded
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_prompts: Dict[str, ActionPrompt] = {}
|
||||
_loaded: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
"""Implement singleton pattern."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._prompts = {}
|
||||
cls._instance._loaded = False
|
||||
return cls._instance
|
||||
|
||||
def load_from_yaml(self, filepath: str) -> int:
|
||||
"""
|
||||
Load action prompts from a YAML file.
|
||||
|
||||
Args:
|
||||
filepath: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Number of prompts loaded
|
||||
|
||||
Raises:
|
||||
ActionPromptLoaderError: If file cannot be read or parsed
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
logger.error("Action prompts file not found", filepath=filepath)
|
||||
raise ActionPromptLoaderError(f"File not found: {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse YAML", filepath=filepath, error=str(e))
|
||||
raise ActionPromptLoaderError(f"Invalid YAML in {filepath}: {e}")
|
||||
|
||||
except IOError as e:
|
||||
logger.error("Failed to read file", filepath=filepath, error=str(e))
|
||||
raise ActionPromptLoaderError(f"Cannot read {filepath}: {e}")
|
||||
|
||||
if not data or 'action_prompts' not in data:
|
||||
logger.error("No action_prompts key in YAML", filepath=filepath)
|
||||
raise ActionPromptLoaderError(f"Missing 'action_prompts' key in {filepath}")
|
||||
|
||||
# Clear existing prompts
|
||||
self._prompts = {}
|
||||
|
||||
# Parse each prompt
|
||||
prompts_data = data['action_prompts']
|
||||
errors = []
|
||||
|
||||
for i, prompt_data in enumerate(prompts_data):
|
||||
try:
|
||||
prompt = ActionPrompt.from_dict(prompt_data)
|
||||
self._prompts[prompt.prompt_id] = prompt
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
errors.append(f"Prompt {i}: {e}")
|
||||
logger.warning(
|
||||
"Failed to parse action prompt",
|
||||
index=i,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
if errors:
|
||||
logger.warning(
|
||||
"Some action prompts failed to load",
|
||||
error_count=len(errors),
|
||||
errors=errors
|
||||
)
|
||||
|
||||
self._loaded = True
|
||||
loaded_count = len(self._prompts)
|
||||
|
||||
logger.info(
|
||||
"Action prompts loaded",
|
||||
filepath=filepath,
|
||||
count=loaded_count,
|
||||
errors=len(errors)
|
||||
)
|
||||
|
||||
return loaded_count
|
||||
|
||||
def get_all_actions(self) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get all loaded action prompts.
|
||||
|
||||
Returns:
|
||||
List of all action prompts
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
return list(self._prompts.values())
|
||||
|
||||
def get_action_by_id(self, prompt_id: str) -> ActionPrompt:
|
||||
"""
|
||||
Get a specific action prompt by ID.
|
||||
|
||||
Args:
|
||||
prompt_id: The unique identifier of the action
|
||||
|
||||
Returns:
|
||||
The ActionPrompt object
|
||||
|
||||
Raises:
|
||||
ActionPromptNotFoundError: If action not found
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if prompt_id not in self._prompts:
|
||||
logger.warning("Action prompt not found", prompt_id=prompt_id)
|
||||
raise ActionPromptNotFoundError(f"Action prompt '{prompt_id}' not found")
|
||||
|
||||
return self._prompts[prompt_id]
|
||||
|
||||
def get_available_actions(
|
||||
self,
|
||||
user_tier: UserTier,
|
||||
location_type: LocationType
|
||||
) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get actions available to a user at a specific location.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
List of available action prompts
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
available = []
|
||||
for prompt in self._prompts.values():
|
||||
if prompt.is_available(user_tier, location_type):
|
||||
available.append(prompt)
|
||||
|
||||
logger.debug(
|
||||
"Filtered available actions",
|
||||
user_tier=user_tier.value,
|
||||
location_type=location_type.value,
|
||||
count=len(available)
|
||||
)
|
||||
|
||||
return available
|
||||
|
||||
def get_actions_by_tier(self, user_tier: UserTier) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get all actions available to a user tier (ignoring location).
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
List of action prompts available to the tier
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
available = []
|
||||
for prompt in self._prompts.values():
|
||||
if prompt._tier_meets_requirement(user_tier):
|
||||
available.append(prompt)
|
||||
|
||||
return available
|
||||
|
||||
def get_actions_by_category(self, category: str) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get all actions in a specific category.
|
||||
|
||||
Args:
|
||||
category: The action category (e.g., "ask_question", "explore")
|
||||
|
||||
Returns:
|
||||
List of action prompts in the category
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
return [
|
||||
prompt for prompt in self._prompts.values()
|
||||
if prompt.category.value == category
|
||||
]
|
||||
|
||||
def get_locked_actions(
|
||||
self,
|
||||
user_tier: UserTier,
|
||||
location_type: LocationType
|
||||
) -> List[ActionPrompt]:
|
||||
"""
|
||||
Get actions that are locked due to tier restrictions.
|
||||
|
||||
Used to show locked actions with upgrade prompts in UI.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
location_type: The current location type
|
||||
|
||||
Returns:
|
||||
List of locked action prompts
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
locked = []
|
||||
for prompt in self._prompts.values():
|
||||
# Must match location but be tier-locked
|
||||
if prompt._location_matches_filter(location_type) and prompt.is_locked(user_tier):
|
||||
locked.append(prompt)
|
||||
|
||||
return locked
|
||||
|
||||
def reload(self, filepath: str) -> int:
|
||||
"""
|
||||
Force reload prompts from YAML file.
|
||||
|
||||
Args:
|
||||
filepath: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Number of prompts loaded
|
||||
"""
|
||||
self._loaded = False
|
||||
return self.load_from_yaml(filepath)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if prompts have been loaded."""
|
||||
return self._loaded
|
||||
|
||||
def get_prompt_count(self) -> int:
|
||||
"""Get the number of loaded prompts."""
|
||||
return len(self._prompts)
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""
|
||||
Ensure prompts are loaded, auto-load from default path if not.
|
||||
|
||||
Raises:
|
||||
ActionPromptLoaderError: If prompts cannot be loaded
|
||||
"""
|
||||
if not self._loaded:
|
||||
# Try default path
|
||||
default_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', 'data', 'action_prompts.yaml'
|
||||
)
|
||||
default_path = os.path.normpath(default_path)
|
||||
|
||||
if os.path.exists(default_path):
|
||||
self.load_from_yaml(default_path)
|
||||
else:
|
||||
raise ActionPromptLoaderError(
|
||||
"Action prompts not loaded. Call load_from_yaml() first."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
|
||||
Primarily for testing purposes.
|
||||
"""
|
||||
cls._instance = None
|
||||
588
api/app/services/appwrite_service.py
Normal file
588
api/app/services/appwrite_service.py
Normal file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Appwrite Service Wrapper
|
||||
|
||||
This module provides a wrapper around the Appwrite SDK for handling user authentication,
|
||||
session management, and user data operations. It abstracts Appwrite's API to provide
|
||||
a clean interface for the application.
|
||||
|
||||
Usage:
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
|
||||
# Initialize service
|
||||
service = AppwriteService()
|
||||
|
||||
# Register a new user
|
||||
user = service.register_user(
|
||||
email="player@example.com",
|
||||
password="SecurePass123!",
|
||||
name="Brave Adventurer"
|
||||
)
|
||||
|
||||
# Login
|
||||
session = service.login_user(
|
||||
email="player@example.com",
|
||||
password="SecurePass123!"
|
||||
)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.account import Account
|
||||
from appwrite.services.users import Users
|
||||
from appwrite.exception import AppwriteException
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserData:
|
||||
"""
|
||||
Data class representing a user in the system.
|
||||
|
||||
Attributes:
|
||||
id: Unique user identifier
|
||||
email: User's email address
|
||||
name: User's display name
|
||||
email_verified: Whether email has been verified
|
||||
tier: User's subscription tier (free, basic, premium, elite)
|
||||
created_at: When the user account was created
|
||||
updated_at: When the user account was last updated
|
||||
"""
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
email_verified: bool
|
||||
tier: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert user data to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"email_verified": self.email_verified,
|
||||
"tier": self.tier,
|
||||
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
||||
"updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionData:
|
||||
"""
|
||||
Data class representing a user session.
|
||||
|
||||
Attributes:
|
||||
session_id: Unique session identifier
|
||||
user_id: User ID associated with this session
|
||||
provider: Authentication provider (email, oauth, etc.)
|
||||
expire: When the session expires
|
||||
"""
|
||||
session_id: str
|
||||
user_id: str
|
||||
provider: str
|
||||
expire: datetime
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert session data to dictionary."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"provider": self.provider,
|
||||
"expire": self.expire.isoformat() if isinstance(self.expire, datetime) else self.expire,
|
||||
}
|
||||
|
||||
|
||||
class AppwriteService:
|
||||
"""
|
||||
Service class for interacting with Appwrite authentication and user management.
|
||||
|
||||
This class provides methods for:
|
||||
- User registration and email verification
|
||||
- User login and logout
|
||||
- Session management
|
||||
- Password reset
|
||||
- User tier management
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the Appwrite service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key (for server-side operations)
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize services
|
||||
self.account = Account(self.client)
|
||||
self.users = Users(self.client)
|
||||
|
||||
logger.info("Appwrite service initialized", endpoint=self.endpoint, project_id=self.project_id)
|
||||
|
||||
def register_user(self, email: str, password: str, name: str) -> UserData:
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
This method:
|
||||
1. Creates a new user in Appwrite Auth
|
||||
2. Sets the user's tier to 'free' in preferences
|
||||
3. Triggers email verification
|
||||
4. Returns user data
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password (will be hashed by Appwrite)
|
||||
name: User's display name
|
||||
|
||||
Returns:
|
||||
UserData object with user information
|
||||
|
||||
Raises:
|
||||
AppwriteException: If registration fails (e.g., email already exists)
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to register new user", email=email, name=name)
|
||||
|
||||
# Generate unique user ID
|
||||
user_id = ID.unique()
|
||||
|
||||
# Create user account
|
||||
user = self.users.create(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
password=password,
|
||||
name=name
|
||||
)
|
||||
|
||||
logger.info("User created successfully", user_id=user['$id'], email=email)
|
||||
|
||||
# Set default tier to 'free' in user preferences
|
||||
self.users.update_prefs(
|
||||
user_id=user['$id'],
|
||||
prefs={
|
||||
'tier': 'free',
|
||||
'tier_updated_at': datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("User tier set to 'free'", user_id=user['$id'])
|
||||
|
||||
# Note: Email verification is handled by Appwrite automatically
|
||||
# when email templates are configured in the Appwrite console.
|
||||
# For server-side user creation, verification emails are sent
|
||||
# automatically if the email provider is configured.
|
||||
#
|
||||
# To manually trigger verification, users can use the Account service
|
||||
# (client-side) after logging in, or configure email verification
|
||||
# settings in the Appwrite console.
|
||||
|
||||
logger.info("User created, email verification handled by Appwrite", user_id=user['$id'], email=email)
|
||||
|
||||
# Return user data
|
||||
return self._user_to_userdata(user)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to register user", email=email, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def login_user(self, email: str, password: str) -> tuple[SessionData, UserData]:
|
||||
"""
|
||||
Authenticate a user and create a session.
|
||||
|
||||
For server-side authentication, we create a temporary client with user
|
||||
credentials to verify them, then create a session using the server SDK.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionData, UserData)
|
||||
|
||||
Raises:
|
||||
AppwriteException: If login fails (invalid credentials, etc.)
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting user login", email=email)
|
||||
|
||||
# Use admin client (with API key) to create session
|
||||
# This is required to get the session secret in the response
|
||||
from appwrite.services.account import Account
|
||||
|
||||
admin_account = Account(self.client) # self.client already has API key set
|
||||
|
||||
# Create email/password session using admin client
|
||||
# When using admin client, the 'secret' field is populated in the response
|
||||
user_session = admin_account.create_email_password_session(
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
logger.info("Session created successfully",
|
||||
user_id=user_session['userId'],
|
||||
session_id=user_session['$id'])
|
||||
|
||||
# Extract session secret from response
|
||||
# Admin client populates this field, unlike regular client
|
||||
session_secret = user_session.get('secret', '')
|
||||
|
||||
if not session_secret:
|
||||
logger.error("Session secret not found in response - this should not happen with admin client")
|
||||
raise AppwriteException("Failed to get session secret", code=500)
|
||||
|
||||
# Get user data using server SDK
|
||||
user = self.users.get(user_id=user_session['userId'])
|
||||
|
||||
# Convert to our data classes
|
||||
session_data = SessionData(
|
||||
session_id=session_secret, # Use the secret, not the session ID
|
||||
user_id=user_session['userId'],
|
||||
provider=user_session['provider'],
|
||||
expire=datetime.fromisoformat(user_session['expire'].replace('Z', '+00:00'))
|
||||
)
|
||||
|
||||
user_data = self._user_to_userdata(user)
|
||||
|
||||
return session_data, user_data
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to login user", email=email, error=str(e), code=e.code)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during login", email=email, error=str(e), exc_info=True)
|
||||
raise AppwriteException(str(e), code=500)
|
||||
|
||||
def logout_user(self, session_id: str) -> bool:
|
||||
"""
|
||||
Log out a user by deleting their session.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to delete
|
||||
|
||||
Returns:
|
||||
True if logout successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If logout fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to logout user", session_id=session_id)
|
||||
|
||||
# For server-side, we need to delete the session using Users service
|
||||
# First get the session to find the user_id
|
||||
# Note: Appwrite doesn't have a direct server-side session delete by session_id
|
||||
# We'll use a workaround by creating a client with the session and deleting it
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.account import Account
|
||||
|
||||
# Create client with the session
|
||||
session_client = Client()
|
||||
session_client.set_endpoint(self.endpoint)
|
||||
session_client.set_project(self.project_id)
|
||||
session_client.set_session(session_id)
|
||||
|
||||
session_account = Account(session_client)
|
||||
|
||||
# Delete the current session
|
||||
session_account.delete_session('current')
|
||||
|
||||
logger.info("User logged out successfully", session_id=session_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to logout user", session_id=session_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def verify_email(self, user_id: str, secret: str) -> bool:
|
||||
"""
|
||||
Verify a user's email address.
|
||||
|
||||
Note: Email verification with server-side SDK requires updating
|
||||
the user's emailVerification status directly, or using Appwrite's
|
||||
built-in verification flow through the Account service (client-side).
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
secret: Verification secret from email link (not validated server-side)
|
||||
|
||||
Returns:
|
||||
True if verification successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If verification fails (invalid/expired secret)
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to verify email", user_id=user_id, secret_provided=bool(secret))
|
||||
|
||||
# For server-side verification, we update the user's email verification status
|
||||
# The secret validation should be done by Appwrite's verification flow
|
||||
# For now, we'll mark the email as verified
|
||||
# In production, you should validate the secret token before updating
|
||||
self.users.update_email_verification(user_id=user_id, email_verification=True)
|
||||
|
||||
logger.info("Email verified successfully", user_id=user_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to verify email", user_id=user_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def request_password_reset(self, email: str) -> bool:
|
||||
"""
|
||||
Request a password reset for a user.
|
||||
|
||||
This sends a password reset email to the user. For security,
|
||||
it always returns True even if the email doesn't exist.
|
||||
|
||||
Note: Password reset is handled through Appwrite's built-in Account
|
||||
service recovery flow. For server-side operations, we would need to
|
||||
create a password recovery token manually.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
|
||||
Returns:
|
||||
Always True (for security - don't reveal if email exists)
|
||||
"""
|
||||
try:
|
||||
logger.info("Password reset requested", email=email)
|
||||
|
||||
# Note: Password reset with server-side SDK requires creating
|
||||
# a recovery token. For now, we'll log this and return success.
|
||||
# In production, configure Appwrite's email templates and use
|
||||
# client-side Account.createRecovery() or implement custom token
|
||||
# generation and email sending.
|
||||
|
||||
logger.warning("Password reset not fully implemented - requires Appwrite email configuration", email=email)
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but still return True for security
|
||||
# Don't reveal whether the email exists
|
||||
logger.warning("Password reset request encountered error", email=email, error=str(e))
|
||||
|
||||
# Always return True to not reveal if email exists
|
||||
return True
|
||||
|
||||
def confirm_password_reset(self, user_id: str, secret: str, password: str) -> bool:
|
||||
"""
|
||||
Confirm a password reset and update the user's password.
|
||||
|
||||
Note: For server-side operations, we update the password directly
|
||||
using the Users service. Secret validation would be handled separately.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
secret: Reset secret from email link (should be validated before calling)
|
||||
password: New password
|
||||
|
||||
Returns:
|
||||
True if password reset successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If reset fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Attempting to reset password", user_id=user_id, secret_provided=bool(secret))
|
||||
|
||||
# For server-side password reset, update the password directly
|
||||
# In production, you should validate the secret token first before calling this
|
||||
# The secret parameter is kept for API compatibility but not validated here
|
||||
self.users.update_password(user_id=user_id, password=password)
|
||||
|
||||
logger.info("Password reset successfully", user_id=user_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to reset password", user_id=user_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def get_user(self, user_id: str) -> UserData:
|
||||
"""
|
||||
Get user data by user ID.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
UserData object
|
||||
|
||||
Raises:
|
||||
AppwriteException: If user not found
|
||||
"""
|
||||
try:
|
||||
user = self.users.get(user_id=user_id)
|
||||
|
||||
return self._user_to_userdata(user)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to fetch user", user_id=user_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData:
|
||||
"""
|
||||
Get session data and validate it's still active.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
SessionData object
|
||||
|
||||
Raises:
|
||||
AppwriteException: If session invalid or expired
|
||||
"""
|
||||
try:
|
||||
# Create a client with the session to validate it
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.account import Account
|
||||
|
||||
session_client = Client()
|
||||
session_client.set_endpoint(self.endpoint)
|
||||
session_client.set_project(self.project_id)
|
||||
session_client.set_session(session_id)
|
||||
|
||||
session_account = Account(session_client)
|
||||
|
||||
# Get the current session (this validates it exists and is active)
|
||||
session = session_account.get_session('current')
|
||||
|
||||
# Check if session is expired
|
||||
expire_time = datetime.fromisoformat(session['expire'].replace('Z', '+00:00'))
|
||||
if expire_time < datetime.now(timezone.utc):
|
||||
logger.warning("Session expired", session_id=session_id, expired_at=expire_time)
|
||||
raise AppwriteException("Session expired", code=401)
|
||||
|
||||
return SessionData(
|
||||
session_id=session['$id'],
|
||||
user_id=session['userId'],
|
||||
provider=session['provider'],
|
||||
expire=expire_time
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to validate session", session_id=session_id, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def get_user_tier(self, user_id: str) -> str:
|
||||
"""
|
||||
Get the user's subscription tier.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Tier string (free, basic, premium, elite)
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching user tier", user_id=user_id)
|
||||
|
||||
user = self.users.get(user_id=user_id)
|
||||
prefs = user.get('prefs', {})
|
||||
tier = prefs.get('tier', 'free')
|
||||
|
||||
logger.debug("User tier retrieved", user_id=user_id, tier=tier)
|
||||
return tier
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to fetch user tier", user_id=user_id, error=str(e), code=e.code)
|
||||
# Default to free tier on error
|
||||
return 'free'
|
||||
|
||||
def set_user_tier(self, user_id: str, tier: str) -> bool:
|
||||
"""
|
||||
Update the user's subscription tier.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
tier: New tier (free, basic, premium, elite)
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If update fails
|
||||
ValueError: If tier is invalid
|
||||
"""
|
||||
valid_tiers = ['free', 'basic', 'premium', 'elite']
|
||||
if tier not in valid_tiers:
|
||||
raise ValueError(f"Invalid tier: {tier}. Must be one of {valid_tiers}")
|
||||
|
||||
try:
|
||||
logger.info("Updating user tier", user_id=user_id, new_tier=tier)
|
||||
|
||||
# Get current preferences
|
||||
user = self.users.get(user_id=user_id)
|
||||
prefs = user.get('prefs', {})
|
||||
|
||||
# Update tier
|
||||
prefs['tier'] = tier
|
||||
prefs['tier_updated_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
self.users.update_prefs(user_id=user_id, prefs=prefs)
|
||||
|
||||
logger.info("User tier updated successfully", user_id=user_id, tier=tier)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to update user tier", user_id=user_id, tier=tier, error=str(e), code=e.code)
|
||||
raise
|
||||
|
||||
def _user_to_userdata(self, user: Dict[str, Any]) -> UserData:
|
||||
"""
|
||||
Convert Appwrite user object to UserData dataclass.
|
||||
|
||||
Args:
|
||||
user: Appwrite user dictionary
|
||||
|
||||
Returns:
|
||||
UserData object
|
||||
"""
|
||||
# Get tier from preferences, default to 'free'
|
||||
prefs = user.get('prefs', {})
|
||||
tier = prefs.get('tier', 'free')
|
||||
|
||||
# Parse timestamps
|
||||
created_at = user.get('$createdAt', datetime.now(timezone.utc).isoformat())
|
||||
updated_at = user.get('$updatedAt', datetime.now(timezone.utc).isoformat())
|
||||
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
if isinstance(updated_at, str):
|
||||
updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00'))
|
||||
|
||||
return UserData(
|
||||
id=user['$id'],
|
||||
email=user['email'],
|
||||
name=user['name'],
|
||||
email_verified=user.get('emailVerification', False),
|
||||
tier=tier,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
1049
api/app/services/character_service.py
Normal file
1049
api/app/services/character_service.py
Normal file
File diff suppressed because it is too large
Load Diff
277
api/app/services/class_loader.py
Normal file
277
api/app/services/class_loader.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
ClassLoader service for loading player class definitions from YAML files.
|
||||
|
||||
This service reads class configuration files and converts them into PlayerClass
|
||||
dataclass instances, providing caching for performance.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.skills import PlayerClass, SkillTree, SkillNode
|
||||
from app.models.stats import Stats
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ClassLoader:
|
||||
"""
|
||||
Loads player class definitions from YAML configuration files.
|
||||
|
||||
This allows game designers to define classes and skill trees without touching code.
|
||||
All class definitions are stored in /app/data/classes/ as YAML files.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the class loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing class YAML files.
|
||||
Defaults to /app/data/classes/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/classes relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "classes")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._class_cache: Dict[str, PlayerClass] = {}
|
||||
|
||||
logger.info("ClassLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_class(self, class_id: str) -> Optional[PlayerClass]:
|
||||
"""
|
||||
Load a single player class by ID.
|
||||
|
||||
Args:
|
||||
class_id: Unique class identifier (e.g., "vanguard")
|
||||
|
||||
Returns:
|
||||
PlayerClass instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if class_id in self._class_cache:
|
||||
logger.debug("Class loaded from cache", class_id=class_id)
|
||||
return self._class_cache[class_id]
|
||||
|
||||
# Construct file path
|
||||
file_path = self.data_dir / f"{class_id}.yaml"
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning("Class file not found", class_id=class_id, file_path=str(file_path))
|
||||
return None
|
||||
|
||||
try:
|
||||
# Load YAML file
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Parse into PlayerClass
|
||||
player_class = self._parse_class_data(data)
|
||||
|
||||
# Cache the result
|
||||
self._class_cache[class_id] = player_class
|
||||
|
||||
logger.info("Class loaded successfully", class_id=class_id)
|
||||
return player_class
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load class", class_id=class_id, error=str(e))
|
||||
return None
|
||||
|
||||
def load_all_classes(self) -> List[PlayerClass]:
|
||||
"""
|
||||
Load all player classes from the data directory.
|
||||
|
||||
Returns:
|
||||
List of PlayerClass instances
|
||||
"""
|
||||
classes = []
|
||||
|
||||
# Find all YAML files in the directory
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Class data directory does not exist", data_dir=str(self.data_dir))
|
||||
return classes
|
||||
|
||||
for file_path in self.data_dir.glob("*.yaml"):
|
||||
class_id = file_path.stem # Get filename without extension
|
||||
player_class = self.load_class(class_id)
|
||||
if player_class:
|
||||
classes.append(player_class)
|
||||
|
||||
logger.info("All classes loaded", count=len(classes))
|
||||
return classes
|
||||
|
||||
def get_class_by_id(self, class_id: str) -> Optional[PlayerClass]:
|
||||
"""
|
||||
Get a player class by ID (alias for load_class).
|
||||
|
||||
Args:
|
||||
class_id: Unique class identifier
|
||||
|
||||
Returns:
|
||||
PlayerClass instance or None if not found
|
||||
"""
|
||||
return self.load_class(class_id)
|
||||
|
||||
def get_all_class_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available class IDs.
|
||||
|
||||
Returns:
|
||||
List of class IDs (e.g., ["vanguard", "assassin", "arcanist"])
|
||||
"""
|
||||
if not self.data_dir.exists():
|
||||
return []
|
||||
|
||||
return [file_path.stem for file_path in self.data_dir.glob("*.yaml")]
|
||||
|
||||
def reload_class(self, class_id: str) -> Optional[PlayerClass]:
|
||||
"""
|
||||
Force reload a class from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when class definitions change.
|
||||
|
||||
Args:
|
||||
class_id: Unique class identifier
|
||||
|
||||
Returns:
|
||||
PlayerClass instance or None if not found
|
||||
"""
|
||||
# Remove from cache if present
|
||||
if class_id in self._class_cache:
|
||||
del self._class_cache[class_id]
|
||||
|
||||
return self.load_class(class_id)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the class cache. Useful for testing."""
|
||||
self._class_cache.clear()
|
||||
logger.info("Class cache cleared")
|
||||
|
||||
def _parse_class_data(self, data: Dict) -> PlayerClass:
|
||||
"""
|
||||
Parse YAML data into a PlayerClass dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
PlayerClass instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["class_id", "name", "description", "base_stats", "skill_trees"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse base stats
|
||||
base_stats = Stats(**data["base_stats"])
|
||||
|
||||
# Parse skill trees
|
||||
skill_trees = []
|
||||
for tree_data in data["skill_trees"]:
|
||||
skill_tree = self._parse_skill_tree(tree_data)
|
||||
skill_trees.append(skill_tree)
|
||||
|
||||
# Get optional fields
|
||||
starting_equipment = data.get("starting_equipment", [])
|
||||
starting_abilities = data.get("starting_abilities", [])
|
||||
|
||||
# Create PlayerClass instance
|
||||
player_class = PlayerClass(
|
||||
class_id=data["class_id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
base_stats=base_stats,
|
||||
skill_trees=skill_trees,
|
||||
starting_equipment=starting_equipment,
|
||||
starting_abilities=starting_abilities
|
||||
)
|
||||
|
||||
return player_class
|
||||
|
||||
def _parse_skill_tree(self, tree_data: Dict) -> SkillTree:
|
||||
"""
|
||||
Parse a skill tree from YAML data.
|
||||
|
||||
Args:
|
||||
tree_data: Dictionary containing skill tree data
|
||||
|
||||
Returns:
|
||||
SkillTree instance
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["tree_id", "name", "description", "nodes"]
|
||||
for field in required_fields:
|
||||
if field not in tree_data:
|
||||
raise ValueError(f"Missing required field in skill tree: {field}")
|
||||
|
||||
# Parse skill nodes
|
||||
nodes = []
|
||||
for node_data in tree_data["nodes"]:
|
||||
skill_node = self._parse_skill_node(node_data)
|
||||
nodes.append(skill_node)
|
||||
|
||||
# Create SkillTree instance
|
||||
skill_tree = SkillTree(
|
||||
tree_id=tree_data["tree_id"],
|
||||
name=tree_data["name"],
|
||||
description=tree_data["description"],
|
||||
nodes=nodes
|
||||
)
|
||||
|
||||
return skill_tree
|
||||
|
||||
def _parse_skill_node(self, node_data: Dict) -> SkillNode:
|
||||
"""
|
||||
Parse a skill node from YAML data.
|
||||
|
||||
Args:
|
||||
node_data: Dictionary containing skill node data
|
||||
|
||||
Returns:
|
||||
SkillNode instance
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["skill_id", "name", "description", "tier", "effects"]
|
||||
for field in required_fields:
|
||||
if field not in node_data:
|
||||
raise ValueError(f"Missing required field in skill node: {field}")
|
||||
|
||||
# Create SkillNode instance
|
||||
skill_node = SkillNode(
|
||||
skill_id=node_data["skill_id"],
|
||||
name=node_data["name"],
|
||||
description=node_data["description"],
|
||||
tier=node_data["tier"],
|
||||
prerequisites=node_data.get("prerequisites", []),
|
||||
effects=node_data.get("effects", {}),
|
||||
unlocked=False # Always start locked
|
||||
)
|
||||
|
||||
return skill_node
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_loader_instance: Optional[ClassLoader] = None
|
||||
|
||||
|
||||
def get_class_loader() -> ClassLoader:
|
||||
"""
|
||||
Get the global ClassLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton ClassLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = ClassLoader()
|
||||
return _loader_instance
|
||||
709
api/app/services/database_init.py
Normal file
709
api/app/services/database_init.py
Normal file
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
Database Initialization Service.
|
||||
|
||||
This service handles programmatic creation of Appwrite database tables,
|
||||
including schema definition, column creation, and index setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class DatabaseInitService:
|
||||
"""
|
||||
Service for initializing Appwrite database tables.
|
||||
|
||||
This service provides methods to:
|
||||
- Create tables if they don't exist
|
||||
- Define table schemas (columns/attributes)
|
||||
- Create indexes for efficient querying
|
||||
- Validate existing table structures
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the database initialization service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("DatabaseInitService initialized", database_id=self.database_id)
|
||||
|
||||
def init_all_tables(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Initialize all application tables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to success status
|
||||
"""
|
||||
results = {}
|
||||
|
||||
logger.info("Initializing all database tables")
|
||||
|
||||
# Initialize characters table
|
||||
try:
|
||||
self.init_characters_table()
|
||||
results['characters'] = True
|
||||
logger.info("Characters table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize characters table", error=str(e))
|
||||
results['characters'] = False
|
||||
|
||||
# Initialize game_sessions table
|
||||
try:
|
||||
self.init_game_sessions_table()
|
||||
results['game_sessions'] = True
|
||||
logger.info("Game sessions table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize game_sessions table", error=str(e))
|
||||
results['game_sessions'] = False
|
||||
|
||||
# Initialize ai_usage_logs table
|
||||
try:
|
||||
self.init_ai_usage_logs_table()
|
||||
results['ai_usage_logs'] = True
|
||||
logger.info("AI usage logs table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize ai_usage_logs table", error=str(e))
|
||||
results['ai_usage_logs'] = False
|
||||
|
||||
success_count = sum(1 for v in results.values() if v)
|
||||
total_count = len(results)
|
||||
|
||||
logger.info("Table initialization complete",
|
||||
success=success_count,
|
||||
total=total_count,
|
||||
results=results)
|
||||
|
||||
return results
|
||||
|
||||
def init_characters_table(self) -> bool:
|
||||
"""
|
||||
Initialize the characters table.
|
||||
|
||||
Table schema:
|
||||
- userId (string, required): Owner's user ID
|
||||
- characterData (string, required): JSON-serialized character data
|
||||
- is_active (boolean, default=True): Soft delete flag
|
||||
- created_at (datetime): Auto-managed creation timestamp
|
||||
- updated_at (datetime): Auto-managed update timestamp
|
||||
|
||||
Indexes:
|
||||
- userId: For general user queries
|
||||
- userId + is_active: Composite index for efficiently fetching active characters
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'characters'
|
||||
|
||||
logger.info("Initializing characters 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("Characters table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Characters table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating characters table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Characters'
|
||||
)
|
||||
logger.info("Characters table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='characterData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON data
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='is_active',
|
||||
column_type='boolean',
|
||||
required=False, # Cannot be required if we want a default value
|
||||
default=True
|
||||
)
|
||||
|
||||
# Note: created_at and updated_at are auto-managed by DatabaseService
|
||||
# through the _parse_row method and timestamp updates
|
||||
|
||||
# Wait for columns to fully propagate in Appwrite before creating indexes
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes for efficient querying
|
||||
# Note: Individual userId index for general user queries
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId',
|
||||
index_type='key',
|
||||
attributes=['userId']
|
||||
)
|
||||
|
||||
# Composite index for the most common query pattern:
|
||||
# Query.equal('userId', user_id) + Query.equal('is_active', True)
|
||||
# This single composite index covers both conditions efficiently
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_is_active',
|
||||
index_type='key',
|
||||
attributes=['userId', 'is_active']
|
||||
)
|
||||
|
||||
logger.info("Characters table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize characters table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_game_sessions_table(self) -> bool:
|
||||
"""
|
||||
Initialize the game_sessions table.
|
||||
|
||||
Table schema:
|
||||
- userId (string, required): Owner's user ID
|
||||
- characterId (string, required): Character ID for this session
|
||||
- sessionData (string, required): JSON-serialized session data
|
||||
- status (string, required): Session status (active, completed, abandoned)
|
||||
- sessionType (string, required): Session type (solo, multiplayer)
|
||||
|
||||
Indexes:
|
||||
- userId: For user session queries
|
||||
- userId + status: For active session queries
|
||||
- characterId: For character session lookups
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'game_sessions'
|
||||
|
||||
logger.info("Initializing game_sessions 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("Game sessions table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Game sessions table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating game_sessions table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Game Sessions'
|
||||
)
|
||||
logger.info("Game sessions table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='characterId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON data
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='status',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionType',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId',
|
||||
index_type='key',
|
||||
attributes=['userId']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_status',
|
||||
index_type='key',
|
||||
attributes=['userId', 'status']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_characterId',
|
||||
index_type='key',
|
||||
attributes=['characterId']
|
||||
)
|
||||
|
||||
logger.info("Game sessions table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize game_sessions table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_ai_usage_logs_table(self) -> bool:
|
||||
"""
|
||||
Initialize the ai_usage_logs table for tracking AI API usage and costs.
|
||||
|
||||
Table schema:
|
||||
- user_id (string, required): User who made the request
|
||||
- timestamp (string, required): ISO timestamp of the request
|
||||
- model (string, required): Model identifier
|
||||
- tokens_input (integer, required): Input token count
|
||||
- tokens_output (integer, required): Output token count
|
||||
- tokens_total (integer, required): Total token count
|
||||
- estimated_cost (float, required): Estimated cost in USD
|
||||
- task_type (string, required): Type of task
|
||||
- session_id (string, optional): Game session ID
|
||||
- character_id (string, optional): Character ID
|
||||
- request_duration_ms (integer): Request duration in milliseconds
|
||||
- success (boolean): Whether request succeeded
|
||||
- error_message (string, optional): Error message if failed
|
||||
|
||||
Indexes:
|
||||
- user_id: For user usage queries
|
||||
- timestamp: For date range queries
|
||||
- user_id + timestamp: Composite for user date range queries
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'ai_usage_logs'
|
||||
|
||||
logger.info("Initializing ai_usage_logs 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("AI usage logs table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("AI usage logs table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating ai_usage_logs table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='AI Usage Logs'
|
||||
)
|
||||
logger.info("AI usage logs table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='user_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
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='model',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_input',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_output',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='tokens_total',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='estimated_cost',
|
||||
column_type='float',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='task_type',
|
||||
column_type='string',
|
||||
size=50,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='session_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='character_id',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='request_duration_ms',
|
||||
column_type='integer',
|
||||
required=False,
|
||||
default=0
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='success',
|
||||
column_type='boolean',
|
||||
required=False,
|
||||
default=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='error_message',
|
||||
column_type='string',
|
||||
size=1000,
|
||||
required=False
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_user_id',
|
||||
index_type='key',
|
||||
attributes=['user_id']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_timestamp',
|
||||
index_type='key',
|
||||
attributes=['timestamp']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_user_id_timestamp',
|
||||
index_type='key',
|
||||
attributes=['user_id', 'timestamp']
|
||||
)
|
||||
|
||||
logger.info("AI usage logs table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize ai_usage_logs table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_column(
|
||||
self,
|
||||
table_id: str,
|
||||
column_id: str,
|
||||
column_type: str,
|
||||
size: Optional[int] = None,
|
||||
required: bool = False,
|
||||
default: Optional[Any] = None,
|
||||
array: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a column in a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
column_id: Column ID
|
||||
column_type: Column type (string, integer, float, boolean, datetime, email, ip, url)
|
||||
size: Column size (for string types)
|
||||
required: Whether column is required
|
||||
default: Default value
|
||||
array: Whether column is an array
|
||||
|
||||
Returns:
|
||||
Column creation response
|
||||
|
||||
Raises:
|
||||
AppwriteException: If column creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating column",
|
||||
table_id=table_id,
|
||||
column_id=column_id,
|
||||
column_type=column_type)
|
||||
|
||||
# Build column parameters (Appwrite SDK uses 'key' not 'column_id')
|
||||
params = {
|
||||
'database_id': self.database_id,
|
||||
'table_id': table_id,
|
||||
'key': column_id,
|
||||
'required': required,
|
||||
'array': array
|
||||
}
|
||||
|
||||
if size is not None:
|
||||
params['size'] = size
|
||||
|
||||
if default is not None:
|
||||
params['default'] = default
|
||||
|
||||
# Create column using the appropriate method based on type
|
||||
if column_type == 'string':
|
||||
result = self.tables_db.create_string_column(**params)
|
||||
elif column_type == 'integer':
|
||||
result = self.tables_db.create_integer_column(**params)
|
||||
elif column_type == 'float':
|
||||
result = self.tables_db.create_float_column(**params)
|
||||
elif column_type == 'boolean':
|
||||
result = self.tables_db.create_boolean_column(**params)
|
||||
elif column_type == 'datetime':
|
||||
result = self.tables_db.create_datetime_column(**params)
|
||||
elif column_type == 'email':
|
||||
result = self.tables_db.create_email_column(**params)
|
||||
else:
|
||||
raise ValueError(f"Unsupported column type: {column_type}")
|
||||
|
||||
logger.info("Column created successfully",
|
||||
table_id=table_id,
|
||||
column_id=column_id)
|
||||
|
||||
return result
|
||||
|
||||
except AppwriteException as e:
|
||||
# If column already exists, log warning but don't fail
|
||||
if e.code == 409: # Conflict - column already exists
|
||||
logger.warning("Column already exists",
|
||||
table_id=table_id,
|
||||
column_id=column_id)
|
||||
return {}
|
||||
logger.error("Failed to create column",
|
||||
table_id=table_id,
|
||||
column_id=column_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_index(
|
||||
self,
|
||||
table_id: str,
|
||||
index_id: str,
|
||||
index_type: str,
|
||||
attributes: List[str],
|
||||
orders: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an index on a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
index_id: Index ID
|
||||
index_type: Index type (key, fulltext, unique)
|
||||
attributes: List of column IDs to index
|
||||
orders: List of sort orders (ASC, DESC) for each attribute
|
||||
|
||||
Returns:
|
||||
Index creation response
|
||||
|
||||
Raises:
|
||||
AppwriteException: If index creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating index",
|
||||
table_id=table_id,
|
||||
index_id=index_id,
|
||||
attributes=attributes)
|
||||
|
||||
result = self.tables_db.create_index(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
key=index_id,
|
||||
type=index_type,
|
||||
columns=attributes, # SDK uses 'columns', not 'attributes'
|
||||
orders=orders or ['ASC'] * len(attributes)
|
||||
)
|
||||
|
||||
logger.info("Index created successfully",
|
||||
table_id=table_id,
|
||||
index_id=index_id)
|
||||
|
||||
return result
|
||||
|
||||
except AppwriteException as e:
|
||||
# If index already exists, log warning but don't fail
|
||||
if e.code == 409: # Conflict - index already exists
|
||||
logger.warning("Index already exists",
|
||||
table_id=table_id,
|
||||
index_id=index_id)
|
||||
return {}
|
||||
logger.error("Failed to create index",
|
||||
table_id=table_id,
|
||||
index_id=index_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_init_service_instance: Optional[DatabaseInitService] = None
|
||||
|
||||
|
||||
def get_database_init_service() -> DatabaseInitService:
|
||||
"""
|
||||
Get the global DatabaseInitService instance.
|
||||
|
||||
Returns:
|
||||
Singleton DatabaseInitService instance
|
||||
"""
|
||||
global _init_service_instance
|
||||
if _init_service_instance is None:
|
||||
_init_service_instance = DatabaseInitService()
|
||||
return _init_service_instance
|
||||
|
||||
|
||||
def init_database() -> Dict[str, bool]:
|
||||
"""
|
||||
Convenience function to initialize all database tables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping table names to success status
|
||||
"""
|
||||
service = get_database_init_service()
|
||||
return service.init_all_tables()
|
||||
441
api/app/services/database_service.py
Normal file
441
api/app/services/database_service.py
Normal file
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Database Service for Appwrite database operations.
|
||||
|
||||
This service wraps the Appwrite Databases SDK to provide a clean interface
|
||||
for CRUD operations on collections. It handles JSON serialization, error handling,
|
||||
and provides structured logging.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseRow:
|
||||
"""
|
||||
Represents a row in an Appwrite table.
|
||||
|
||||
Attributes:
|
||||
id: Row ID
|
||||
table_id: Table ID
|
||||
data: Row data (parsed from JSON)
|
||||
created_at: Creation timestamp
|
||||
updated_at: Last update timestamp
|
||||
"""
|
||||
id: str
|
||||
table_id: str
|
||||
data: Dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert row to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"table_id": self.table_id,
|
||||
"data": self.data,
|
||||
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
||||
"updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""
|
||||
Service for interacting with Appwrite database tables.
|
||||
|
||||
This service provides methods for:
|
||||
- Creating rows
|
||||
- Reading rows by ID or query
|
||||
- Updating rows
|
||||
- Deleting rows
|
||||
- Querying with filters
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the database service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("DatabaseService initialized", database_id=self.database_id)
|
||||
|
||||
def create_row(
|
||||
self,
|
||||
table_id: str,
|
||||
data: Dict[str, Any],
|
||||
row_id: Optional[str] = None,
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> DatabaseRow:
|
||||
"""
|
||||
Create a new row in a table.
|
||||
|
||||
Args:
|
||||
table_id: Table ID (e.g., "characters")
|
||||
data: Row data (will be JSON-serialized if needed)
|
||||
row_id: Optional custom row ID (auto-generated if None)
|
||||
permissions: Optional permissions array
|
||||
|
||||
Returns:
|
||||
DatabaseRow with created row
|
||||
|
||||
Raises:
|
||||
AppwriteException: If creation fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating row", table_id=table_id, has_custom_id=bool(row_id))
|
||||
|
||||
# Generate ID if not provided
|
||||
if row_id is None:
|
||||
row_id = ID.unique()
|
||||
|
||||
# Create row (Appwrite manages timestamps automatically via $createdAt/$updatedAt)
|
||||
result = self.tables_db.create_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
data=data,
|
||||
permissions=permissions or []
|
||||
)
|
||||
|
||||
logger.info("Row created successfully",
|
||||
table_id=table_id,
|
||||
row_id=result['$id'])
|
||||
|
||||
return self._parse_row(result, table_id)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to create row",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def get_row(self, table_id: str, row_id: str) -> Optional[DatabaseRow]:
|
||||
"""
|
||||
Get a row by ID.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
row_id: Row ID
|
||||
|
||||
Returns:
|
||||
DatabaseRow or None if not found
|
||||
|
||||
Raises:
|
||||
AppwriteException: If retrieval fails (except 404)
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching row", table_id=table_id, row_id=row_id)
|
||||
|
||||
result = self.tables_db.get_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id
|
||||
)
|
||||
|
||||
return self._parse_row(result, table_id)
|
||||
|
||||
except AppwriteException as e:
|
||||
if e.code == 404:
|
||||
logger.warning("Row not found",
|
||||
table_id=table_id,
|
||||
row_id=row_id)
|
||||
return None
|
||||
logger.error("Failed to fetch row",
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def update_row(
|
||||
self,
|
||||
table_id: str,
|
||||
row_id: str,
|
||||
data: Dict[str, Any],
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> DatabaseRow:
|
||||
"""
|
||||
Update an existing row.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
row_id: Row ID
|
||||
data: New row data (partial updates supported)
|
||||
permissions: Optional permissions array
|
||||
|
||||
Returns:
|
||||
DatabaseRow with updated row
|
||||
|
||||
Raises:
|
||||
AppwriteException: If update fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Updating row", table_id=table_id, row_id=row_id)
|
||||
|
||||
# Update row (Appwrite manages timestamps automatically via $updatedAt)
|
||||
result = self.tables_db.update_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
data=data,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
logger.info("Row updated successfully",
|
||||
table_id=table_id,
|
||||
row_id=row_id)
|
||||
|
||||
return self._parse_row(result, table_id)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to update row",
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def delete_row(self, table_id: str, row_id: str) -> bool:
|
||||
"""
|
||||
Delete a row.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
row_id: Row ID
|
||||
|
||||
Returns:
|
||||
True if deletion successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If deletion fails
|
||||
"""
|
||||
try:
|
||||
logger.info("Deleting row", table_id=table_id, row_id=row_id)
|
||||
|
||||
self.tables_db.delete_row(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
row_id=row_id
|
||||
)
|
||||
|
||||
logger.info("Row deleted successfully",
|
||||
table_id=table_id,
|
||||
row_id=row_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to delete row",
|
||||
table_id=table_id,
|
||||
row_id=row_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def list_rows(
|
||||
self,
|
||||
table_id: str,
|
||||
queries: Optional[List[str]] = None,
|
||||
limit: int = 25,
|
||||
offset: int = 0
|
||||
) -> List[DatabaseRow]:
|
||||
"""
|
||||
List rows in a table with optional filtering.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
queries: Optional Appwrite query filters
|
||||
limit: Maximum rows to return (default 25, max 100)
|
||||
offset: Number of rows to skip
|
||||
|
||||
Returns:
|
||||
List of DatabaseRow instances
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
logger.debug("Listing rows",
|
||||
table_id=table_id,
|
||||
has_queries=bool(queries),
|
||||
limit=limit,
|
||||
offset=offset)
|
||||
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
queries=queries or []
|
||||
)
|
||||
|
||||
rows = [self._parse_row(row, table_id) for row in result['rows']]
|
||||
|
||||
logger.debug("Rows listed successfully",
|
||||
table_id=table_id,
|
||||
count=len(rows),
|
||||
total=result.get('total', len(rows)))
|
||||
|
||||
return rows
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to list rows",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def count_rows(self, table_id: str, queries: Optional[List[str]] = None) -> int:
|
||||
"""
|
||||
Count rows in a table with optional filtering.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
queries: Optional Appwrite query filters
|
||||
|
||||
Returns:
|
||||
Row count
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
logger.debug("Counting rows", table_id=table_id, has_queries=bool(queries))
|
||||
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
queries=queries or []
|
||||
)
|
||||
|
||||
count = result.get('total', len(result.get('rows', [])))
|
||||
logger.debug("Rows counted", table_id=table_id, count=count)
|
||||
return count
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to count rows",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _parse_row(self, row: Dict[str, Any], table_id: str) -> DatabaseRow:
|
||||
"""
|
||||
Parse Appwrite row into DatabaseRow.
|
||||
|
||||
Args:
|
||||
row: Appwrite row dictionary
|
||||
table_id: Table ID
|
||||
|
||||
Returns:
|
||||
DatabaseRow instance
|
||||
"""
|
||||
# Extract metadata
|
||||
row_id = row['$id']
|
||||
created_at = row.get('$createdAt', datetime.now(timezone.utc).isoformat())
|
||||
updated_at = row.get('$updatedAt', datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# Parse timestamps
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
if isinstance(updated_at, str):
|
||||
updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00'))
|
||||
|
||||
# Remove Appwrite metadata from data
|
||||
data = {k: v for k, v in row.items() if not k.startswith('$')}
|
||||
|
||||
return DatabaseRow(
|
||||
id=row_id,
|
||||
table_id=table_id,
|
||||
data=data,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
|
||||
# Backward compatibility aliases (deprecated, use new methods)
|
||||
def create_document(self, collection_id: str, data: Dict[str, Any],
|
||||
document_id: Optional[str] = None,
|
||||
permissions: Optional[List[str]] = None) -> DatabaseRow:
|
||||
"""Deprecated: Use create_row() instead."""
|
||||
logger.warning("create_document() is deprecated, use create_row() instead")
|
||||
return self.create_row(collection_id, data, document_id, permissions)
|
||||
|
||||
def get_document(self, collection_id: str, document_id: str) -> Optional[DatabaseRow]:
|
||||
"""Deprecated: Use get_row() instead."""
|
||||
logger.warning("get_document() is deprecated, use get_row() instead")
|
||||
return self.get_row(collection_id, document_id)
|
||||
|
||||
def update_document(self, collection_id: str, document_id: str,
|
||||
data: Dict[str, Any],
|
||||
permissions: Optional[List[str]] = None) -> DatabaseRow:
|
||||
"""Deprecated: Use update_row() instead."""
|
||||
logger.warning("update_document() is deprecated, use update_row() instead")
|
||||
return self.update_row(collection_id, document_id, data, permissions)
|
||||
|
||||
def delete_document(self, collection_id: str, document_id: str) -> bool:
|
||||
"""Deprecated: Use delete_row() instead."""
|
||||
logger.warning("delete_document() is deprecated, use delete_row() instead")
|
||||
return self.delete_row(collection_id, document_id)
|
||||
|
||||
def list_documents(self, collection_id: str, queries: Optional[List[str]] = None,
|
||||
limit: int = 25, offset: int = 0) -> List[DatabaseRow]:
|
||||
"""Deprecated: Use list_rows() instead."""
|
||||
logger.warning("list_documents() is deprecated, use list_rows() instead")
|
||||
return self.list_rows(collection_id, queries, limit, offset)
|
||||
|
||||
def count_documents(self, collection_id: str, queries: Optional[List[str]] = None) -> int:
|
||||
"""Deprecated: Use count_rows() instead."""
|
||||
logger.warning("count_documents() is deprecated, use count_rows() instead")
|
||||
return self.count_rows(collection_id, queries)
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
DatabaseDocument = DatabaseRow
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[DatabaseService] = None
|
||||
|
||||
|
||||
def get_database_service() -> DatabaseService:
|
||||
"""
|
||||
Get the global DatabaseService instance.
|
||||
|
||||
Returns:
|
||||
Singleton DatabaseService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = DatabaseService()
|
||||
return _service_instance
|
||||
351
api/app/services/item_validator.py
Normal file
351
api/app/services/item_validator.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Item validation service for AI-granted items.
|
||||
|
||||
This module validates and resolves items that the AI grants to players during
|
||||
gameplay, ensuring they meet character requirements and game balance rules.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
import yaml
|
||||
|
||||
from app.models.items import Item
|
||||
from app.models.enums import ItemType
|
||||
from app.models.character import Character
|
||||
from app.ai.response_parser import ItemGrant
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ItemValidationError(Exception):
|
||||
"""
|
||||
Exception raised when an item fails validation.
|
||||
|
||||
Attributes:
|
||||
message: Human-readable error message
|
||||
item_grant: The ItemGrant that failed validation
|
||||
reason: Machine-readable reason code
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, item_grant: ItemGrant, reason: str):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.item_grant = item_grant
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class ItemValidator:
|
||||
"""
|
||||
Validates and resolves items granted by the AI.
|
||||
|
||||
This service:
|
||||
1. Resolves item references (by ID or creates generic items)
|
||||
2. Validates items against character requirements
|
||||
3. Logs validation failures for review
|
||||
"""
|
||||
|
||||
# Map of generic item type strings to ItemType enums
|
||||
TYPE_MAP = {
|
||||
"weapon": ItemType.WEAPON,
|
||||
"armor": ItemType.ARMOR,
|
||||
"consumable": ItemType.CONSUMABLE,
|
||||
"quest_item": ItemType.QUEST_ITEM,
|
||||
}
|
||||
|
||||
def __init__(self, data_path: Optional[Path] = None):
|
||||
"""
|
||||
Initialize the item validator.
|
||||
|
||||
Args:
|
||||
data_path: Path to game data directory. Defaults to app/data/
|
||||
"""
|
||||
if data_path is None:
|
||||
# Default to api/app/data/
|
||||
data_path = Path(__file__).parent.parent / "data"
|
||||
|
||||
self.data_path = data_path
|
||||
self._item_registry: dict[str, dict] = {}
|
||||
self._generic_templates: dict[str, dict] = {}
|
||||
self._load_data()
|
||||
|
||||
logger.info(
|
||||
"ItemValidator initialized",
|
||||
items_loaded=len(self._item_registry),
|
||||
generic_templates_loaded=len(self._generic_templates)
|
||||
)
|
||||
|
||||
def _load_data(self) -> None:
|
||||
"""Load item data from YAML files."""
|
||||
# Load main item registry if it exists
|
||||
items_file = self.data_path / "items.yaml"
|
||||
if items_file.exists():
|
||||
with open(items_file) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
self._item_registry = data.get("items", {})
|
||||
|
||||
# Load generic item templates
|
||||
generic_file = self.data_path / "generic_items.yaml"
|
||||
if generic_file.exists():
|
||||
with open(generic_file) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
self._generic_templates = data.get("templates", {})
|
||||
|
||||
def resolve_item(self, item_grant: ItemGrant) -> Item:
|
||||
"""
|
||||
Resolve an ItemGrant to an actual Item instance.
|
||||
|
||||
For existing items (by item_id), looks up from item registry.
|
||||
For generic items (by name/type), creates a new Item.
|
||||
|
||||
Args:
|
||||
item_grant: The ItemGrant from AI response
|
||||
|
||||
Returns:
|
||||
Resolved Item instance
|
||||
|
||||
Raises:
|
||||
ItemValidationError: If item cannot be resolved
|
||||
"""
|
||||
if item_grant.is_existing_item():
|
||||
return self._resolve_existing_item(item_grant)
|
||||
elif item_grant.is_generic_item():
|
||||
return self._create_generic_item(item_grant)
|
||||
else:
|
||||
raise ItemValidationError(
|
||||
"ItemGrant has neither item_id nor name",
|
||||
item_grant,
|
||||
"INVALID_ITEM_GRANT"
|
||||
)
|
||||
|
||||
def _resolve_existing_item(self, item_grant: ItemGrant) -> Item:
|
||||
"""
|
||||
Look up an existing item by ID.
|
||||
|
||||
Args:
|
||||
item_grant: ItemGrant with item_id set
|
||||
|
||||
Returns:
|
||||
Item instance from registry
|
||||
|
||||
Raises:
|
||||
ItemValidationError: If item not found
|
||||
"""
|
||||
item_id = item_grant.item_id
|
||||
|
||||
if item_id not in self._item_registry:
|
||||
logger.warning(
|
||||
"Item not found in registry",
|
||||
item_id=item_id
|
||||
)
|
||||
raise ItemValidationError(
|
||||
f"Unknown item_id: {item_id}",
|
||||
item_grant,
|
||||
"ITEM_NOT_FOUND"
|
||||
)
|
||||
|
||||
item_data = self._item_registry[item_id]
|
||||
|
||||
# Convert to Item instance
|
||||
return Item.from_dict({
|
||||
"item_id": item_id,
|
||||
**item_data
|
||||
})
|
||||
|
||||
def _create_generic_item(self, item_grant: ItemGrant) -> Item:
|
||||
"""
|
||||
Create a generic item from AI-provided details.
|
||||
|
||||
Generic items are simple items with no special stats,
|
||||
suitable for mundane objects like torches, food, etc.
|
||||
|
||||
Args:
|
||||
item_grant: ItemGrant with name, type, description
|
||||
|
||||
Returns:
|
||||
New Item instance
|
||||
|
||||
Raises:
|
||||
ItemValidationError: If item type is invalid
|
||||
"""
|
||||
# Validate item type
|
||||
item_type_str = (item_grant.item_type or "consumable").lower()
|
||||
if item_type_str not in self.TYPE_MAP:
|
||||
logger.warning(
|
||||
"Invalid item type from AI",
|
||||
item_type=item_type_str,
|
||||
item_name=item_grant.name
|
||||
)
|
||||
# Default to consumable for unknown types
|
||||
item_type_str = "consumable"
|
||||
|
||||
item_type = self.TYPE_MAP[item_type_str]
|
||||
|
||||
# Generate unique ID for this item instance
|
||||
item_id = f"generic_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Check if we have a template for this item name
|
||||
template = self._find_template(item_grant.name or "")
|
||||
|
||||
if template:
|
||||
# Use template values as defaults
|
||||
return Item(
|
||||
item_id=item_id,
|
||||
name=item_grant.name or template.get("name", "Unknown Item"),
|
||||
item_type=item_type,
|
||||
description=item_grant.description or template.get("description", ""),
|
||||
value=item_grant.value or template.get("value", 0),
|
||||
is_tradeable=template.get("is_tradeable", True),
|
||||
required_level=template.get("required_level", 1),
|
||||
)
|
||||
else:
|
||||
# Create with provided values only
|
||||
return Item(
|
||||
item_id=item_id,
|
||||
name=item_grant.name or "Unknown Item",
|
||||
item_type=item_type,
|
||||
description=item_grant.description or "A simple item.",
|
||||
value=item_grant.value,
|
||||
is_tradeable=True,
|
||||
required_level=1,
|
||||
)
|
||||
|
||||
def _find_template(self, item_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Find a generic item template by name.
|
||||
|
||||
Uses case-insensitive partial matching.
|
||||
|
||||
Args:
|
||||
item_name: Name of the item to find
|
||||
|
||||
Returns:
|
||||
Template dict or None if not found
|
||||
"""
|
||||
name_lower = item_name.lower()
|
||||
|
||||
# Exact match first
|
||||
if name_lower in self._generic_templates:
|
||||
return self._generic_templates[name_lower]
|
||||
|
||||
# Partial match
|
||||
for template_name, template in self._generic_templates.items():
|
||||
if template_name in name_lower or name_lower in template_name:
|
||||
return template
|
||||
|
||||
return None
|
||||
|
||||
def validate_item_for_character(
|
||||
self,
|
||||
item: Item,
|
||||
character: Character
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate that a character can receive an item.
|
||||
|
||||
Checks:
|
||||
- Level requirements
|
||||
- Class restrictions
|
||||
|
||||
Args:
|
||||
item: The Item to validate
|
||||
character: The Character to receive the item
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check level requirement
|
||||
if item.required_level > character.level:
|
||||
error_msg = (
|
||||
f"Item '{item.name}' requires level {item.required_level}, "
|
||||
f"but character is level {character.level}"
|
||||
)
|
||||
logger.warning(
|
||||
"Item validation failed: level requirement",
|
||||
item_name=item.name,
|
||||
required_level=item.required_level,
|
||||
character_level=character.level,
|
||||
character_name=character.name
|
||||
)
|
||||
return False, error_msg
|
||||
|
||||
# Check class restriction
|
||||
if item.required_class:
|
||||
character_class = character.player_class.class_id
|
||||
if item.required_class.lower() != character_class.lower():
|
||||
error_msg = (
|
||||
f"Item '{item.name}' requires class {item.required_class}, "
|
||||
f"but character is {character_class}"
|
||||
)
|
||||
logger.warning(
|
||||
"Item validation failed: class restriction",
|
||||
item_name=item.name,
|
||||
required_class=item.required_class,
|
||||
character_class=character_class,
|
||||
character_name=character.name
|
||||
)
|
||||
return False, error_msg
|
||||
|
||||
return True, None
|
||||
|
||||
def validate_and_resolve_item(
|
||||
self,
|
||||
item_grant: ItemGrant,
|
||||
character: Character
|
||||
) -> tuple[Optional[Item], Optional[str]]:
|
||||
"""
|
||||
Resolve an item grant and validate it for a character.
|
||||
|
||||
This is the main entry point for processing AI-granted items.
|
||||
|
||||
Args:
|
||||
item_grant: The ItemGrant from AI response
|
||||
character: The Character to receive the item
|
||||
|
||||
Returns:
|
||||
Tuple of (Item if valid else None, error_message if invalid else None)
|
||||
"""
|
||||
try:
|
||||
# Resolve the item
|
||||
item = self.resolve_item(item_grant)
|
||||
|
||||
# Validate for character
|
||||
is_valid, error_msg = self.validate_item_for_character(item, character)
|
||||
|
||||
if not is_valid:
|
||||
return None, error_msg
|
||||
|
||||
logger.info(
|
||||
"Item validated successfully",
|
||||
item_name=item.name,
|
||||
item_id=item.item_id,
|
||||
character_name=character.name
|
||||
)
|
||||
return item, None
|
||||
|
||||
except ItemValidationError as e:
|
||||
logger.warning(
|
||||
"Item resolution failed",
|
||||
error=e.message,
|
||||
reason=e.reason
|
||||
)
|
||||
return None, e.message
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_validator_instance: Optional[ItemValidator] = None
|
||||
|
||||
|
||||
def get_item_validator() -> ItemValidator:
|
||||
"""
|
||||
Get or create the global ItemValidator instance.
|
||||
|
||||
Returns:
|
||||
ItemValidator singleton instance
|
||||
"""
|
||||
global _validator_instance
|
||||
if _validator_instance is None:
|
||||
_validator_instance = ItemValidator()
|
||||
return _validator_instance
|
||||
326
api/app/services/location_loader.py
Normal file
326
api/app/services/location_loader.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
LocationLoader service for loading location definitions from YAML files.
|
||||
|
||||
This service reads location configuration files and converts them into Location
|
||||
dataclass instances, providing caching for performance. Locations are organized
|
||||
by region subdirectories.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.location import Location, Region
|
||||
from app.models.enums import LocationType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LocationLoader:
|
||||
"""
|
||||
Loads location definitions from YAML configuration files.
|
||||
|
||||
Locations are organized in region subdirectories:
|
||||
/app/data/locations/
|
||||
regions/
|
||||
crossville.yaml
|
||||
crossville/
|
||||
crossville_village.yaml
|
||||
crossville_tavern.yaml
|
||||
|
||||
This allows game designers to define world locations without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the location loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing location YAML files.
|
||||
Defaults to /app/data/locations/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/locations relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "locations")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._location_cache: Dict[str, Location] = {}
|
||||
self._region_cache: Dict[str, Region] = {}
|
||||
|
||||
logger.info("LocationLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_location(self, location_id: str) -> Optional[Location]:
|
||||
"""
|
||||
Load a single location by ID.
|
||||
|
||||
Searches all region subdirectories for the location file.
|
||||
|
||||
Args:
|
||||
location_id: Unique location identifier (e.g., "crossville_tavern")
|
||||
|
||||
Returns:
|
||||
Location instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if location_id in self._location_cache:
|
||||
logger.debug("Location loaded from cache", location_id=location_id)
|
||||
return self._location_cache[location_id]
|
||||
|
||||
# Search in region subdirectories
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Location data directory does not exist", data_dir=str(self.data_dir))
|
||||
return None
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
# Skip non-directories and the regions folder
|
||||
if not region_dir.is_dir() or region_dir.name == "regions":
|
||||
continue
|
||||
|
||||
file_path = region_dir / f"{location_id}.yaml"
|
||||
if file_path.exists():
|
||||
return self._load_location_file(file_path)
|
||||
|
||||
logger.warning("Location not found", location_id=location_id)
|
||||
return None
|
||||
|
||||
def _load_location_file(self, file_path: Path) -> Optional[Location]:
|
||||
"""
|
||||
Load a location from a specific file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Location instance or None if loading fails
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
location = self._parse_location_data(data)
|
||||
self._location_cache[location.location_id] = location
|
||||
|
||||
logger.info("Location loaded successfully", location_id=location.location_id)
|
||||
return location
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load location", file=str(file_path), error=str(e))
|
||||
return None
|
||||
|
||||
def _parse_location_data(self, data: Dict) -> Location:
|
||||
"""
|
||||
Parse YAML data into a Location dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
Location instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["location_id", "name", "region_id", "description"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse location type - default to town
|
||||
location_type_str = data.get("location_type", "town")
|
||||
try:
|
||||
location_type = LocationType(location_type_str)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid location type, defaulting to town",
|
||||
location_id=data["location_id"],
|
||||
invalid_type=location_type_str
|
||||
)
|
||||
location_type = LocationType.TOWN
|
||||
|
||||
return Location(
|
||||
location_id=data["location_id"],
|
||||
name=data["name"],
|
||||
location_type=location_type,
|
||||
region_id=data["region_id"],
|
||||
description=data["description"],
|
||||
lore=data.get("lore"),
|
||||
ambient_description=data.get("ambient_description"),
|
||||
available_quests=data.get("available_quests", []),
|
||||
npc_ids=data.get("npc_ids", []),
|
||||
discoverable_locations=data.get("discoverable_locations", []),
|
||||
is_starting_location=data.get("is_starting_location", False),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def load_all_locations(self) -> List[Location]:
|
||||
"""
|
||||
Load all locations from all region directories.
|
||||
|
||||
Returns:
|
||||
List of Location instances
|
||||
"""
|
||||
locations = []
|
||||
|
||||
if not self.data_dir.exists():
|
||||
logger.error("Location data directory does not exist", data_dir=str(self.data_dir))
|
||||
return locations
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
# Skip non-directories and the regions folder
|
||||
if not region_dir.is_dir() or region_dir.name == "regions":
|
||||
continue
|
||||
|
||||
for file_path in region_dir.glob("*.yaml"):
|
||||
location = self._load_location_file(file_path)
|
||||
if location:
|
||||
locations.append(location)
|
||||
|
||||
logger.info("All locations loaded", count=len(locations))
|
||||
return locations
|
||||
|
||||
def load_region(self, region_id: str) -> Optional[Region]:
|
||||
"""
|
||||
Load a region definition.
|
||||
|
||||
Args:
|
||||
region_id: Unique region identifier (e.g., "crossville")
|
||||
|
||||
Returns:
|
||||
Region instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if region_id in self._region_cache:
|
||||
logger.debug("Region loaded from cache", region_id=region_id)
|
||||
return self._region_cache[region_id]
|
||||
|
||||
file_path = self.data_dir / "regions" / f"{region_id}.yaml"
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning("Region file not found", region_id=region_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
region = Region.from_dict(data)
|
||||
self._region_cache[region_id] = region
|
||||
|
||||
logger.info("Region loaded successfully", region_id=region_id)
|
||||
return region
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load region", region_id=region_id, error=str(e))
|
||||
return None
|
||||
|
||||
def get_locations_in_region(self, region_id: str) -> List[Location]:
|
||||
"""
|
||||
Get all locations belonging to a specific region.
|
||||
|
||||
Args:
|
||||
region_id: Region identifier
|
||||
|
||||
Returns:
|
||||
List of Location instances in this region
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return [
|
||||
loc for loc in self._location_cache.values()
|
||||
if loc.region_id == region_id
|
||||
]
|
||||
|
||||
def get_starting_locations(self) -> List[Location]:
|
||||
"""
|
||||
Get all locations that can be starting points.
|
||||
|
||||
Returns:
|
||||
List of Location instances marked as starting locations
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return [
|
||||
loc for loc in self._location_cache.values()
|
||||
if loc.is_starting_location
|
||||
]
|
||||
|
||||
def get_location_by_type(self, location_type: LocationType) -> List[Location]:
|
||||
"""
|
||||
Get all locations of a specific type.
|
||||
|
||||
Args:
|
||||
location_type: Type to filter by
|
||||
|
||||
Returns:
|
||||
List of Location instances of this type
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return [
|
||||
loc for loc in self._location_cache.values()
|
||||
if loc.location_type == location_type
|
||||
]
|
||||
|
||||
def get_all_location_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available location IDs.
|
||||
|
||||
Returns:
|
||||
List of location IDs
|
||||
"""
|
||||
# Load all locations if cache is empty
|
||||
if not self._location_cache:
|
||||
self.load_all_locations()
|
||||
|
||||
return list(self._location_cache.keys())
|
||||
|
||||
def reload_location(self, location_id: str) -> Optional[Location]:
|
||||
"""
|
||||
Force reload a location from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when location definitions change.
|
||||
|
||||
Args:
|
||||
location_id: Unique location identifier
|
||||
|
||||
Returns:
|
||||
Location instance or None if not found
|
||||
"""
|
||||
# Remove from cache if present
|
||||
if location_id in self._location_cache:
|
||||
del self._location_cache[location_id]
|
||||
|
||||
return self.load_location(location_id)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data. Useful for testing."""
|
||||
self._location_cache.clear()
|
||||
self._region_cache.clear()
|
||||
logger.info("Location cache cleared")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_loader_instance: Optional[LocationLoader] = None
|
||||
|
||||
|
||||
def get_location_loader() -> LocationLoader:
|
||||
"""
|
||||
Get the global LocationLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton LocationLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = LocationLoader()
|
||||
return _loader_instance
|
||||
385
api/app/services/npc_loader.py
Normal file
385
api/app/services/npc_loader.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
NPCLoader service for loading NPC definitions from YAML files.
|
||||
|
||||
This service reads NPC configuration files and converts them into NPC
|
||||
dataclass instances, providing caching for performance. NPCs are organized
|
||||
by region subdirectories.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.npc import (
|
||||
NPC,
|
||||
NPCPersonality,
|
||||
NPCAppearance,
|
||||
NPCKnowledge,
|
||||
NPCKnowledgeCondition,
|
||||
NPCRelationship,
|
||||
NPCInventoryItem,
|
||||
NPCDialogueHooks,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class NPCLoader:
|
||||
"""
|
||||
Loads NPC definitions from YAML configuration files.
|
||||
|
||||
NPCs are organized in region subdirectories:
|
||||
/app/data/npcs/
|
||||
crossville/
|
||||
npc_grom_001.yaml
|
||||
npc_mira_001.yaml
|
||||
|
||||
This allows game designers to define NPCs without touching code.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the NPC loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing NPC YAML files.
|
||||
Defaults to /app/data/npcs/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/npcs relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "npcs")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._npc_cache: Dict[str, NPC] = {}
|
||||
self._location_npc_cache: Dict[str, List[str]] = {}
|
||||
|
||||
logger.info("NPCLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_npc(self, npc_id: str) -> Optional[NPC]:
|
||||
"""
|
||||
Load a single NPC by ID.
|
||||
|
||||
Searches all region subdirectories for the NPC file.
|
||||
|
||||
Args:
|
||||
npc_id: Unique NPC identifier (e.g., "npc_grom_001")
|
||||
|
||||
Returns:
|
||||
NPC instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if npc_id in self._npc_cache:
|
||||
logger.debug("NPC loaded from cache", npc_id=npc_id)
|
||||
return self._npc_cache[npc_id]
|
||||
|
||||
# Search in region subdirectories
|
||||
if not self.data_dir.exists():
|
||||
logger.error("NPC data directory does not exist", data_dir=str(self.data_dir))
|
||||
return None
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
if not region_dir.is_dir():
|
||||
continue
|
||||
|
||||
file_path = region_dir / f"{npc_id}.yaml"
|
||||
if file_path.exists():
|
||||
return self._load_npc_file(file_path)
|
||||
|
||||
logger.warning("NPC not found", npc_id=npc_id)
|
||||
return None
|
||||
|
||||
def _load_npc_file(self, file_path: Path) -> Optional[NPC]:
|
||||
"""
|
||||
Load an NPC from a specific file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
NPC instance or None if loading fails
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
npc = self._parse_npc_data(data)
|
||||
self._npc_cache[npc.npc_id] = npc
|
||||
|
||||
# Update location cache
|
||||
if npc.location_id not in self._location_npc_cache:
|
||||
self._location_npc_cache[npc.location_id] = []
|
||||
if npc.npc_id not in self._location_npc_cache[npc.location_id]:
|
||||
self._location_npc_cache[npc.location_id].append(npc.npc_id)
|
||||
|
||||
logger.info("NPC loaded successfully", npc_id=npc.npc_id)
|
||||
return npc
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load NPC", file=str(file_path), error=str(e))
|
||||
return None
|
||||
|
||||
def _parse_npc_data(self, data: Dict) -> NPC:
|
||||
"""
|
||||
Parse YAML data into an NPC dataclass.
|
||||
|
||||
Args:
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
NPC instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["npc_id", "name", "role", "location_id"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Parse personality
|
||||
personality_data = data.get("personality", {})
|
||||
personality = NPCPersonality(
|
||||
traits=personality_data.get("traits", []),
|
||||
speech_style=personality_data.get("speech_style", ""),
|
||||
quirks=personality_data.get("quirks", []),
|
||||
)
|
||||
|
||||
# Parse appearance
|
||||
appearance_data = data.get("appearance", {})
|
||||
if isinstance(appearance_data, str):
|
||||
appearance = NPCAppearance(brief=appearance_data)
|
||||
else:
|
||||
appearance = NPCAppearance(
|
||||
brief=appearance_data.get("brief", ""),
|
||||
detailed=appearance_data.get("detailed"),
|
||||
)
|
||||
|
||||
# Parse knowledge (optional)
|
||||
knowledge = None
|
||||
if data.get("knowledge"):
|
||||
knowledge_data = data["knowledge"]
|
||||
conditions = [
|
||||
NPCKnowledgeCondition(
|
||||
condition=c.get("condition", ""),
|
||||
reveals=c.get("reveals", ""),
|
||||
)
|
||||
for c in knowledge_data.get("will_share_if", [])
|
||||
]
|
||||
knowledge = NPCKnowledge(
|
||||
public=knowledge_data.get("public", []),
|
||||
secret=knowledge_data.get("secret", []),
|
||||
will_share_if=conditions,
|
||||
)
|
||||
|
||||
# Parse relationships
|
||||
relationships = [
|
||||
NPCRelationship(
|
||||
npc_id=r["npc_id"],
|
||||
attitude=r["attitude"],
|
||||
reason=r.get("reason"),
|
||||
)
|
||||
for r in data.get("relationships", [])
|
||||
]
|
||||
|
||||
# Parse inventory
|
||||
inventory = []
|
||||
for item_data in data.get("inventory_for_sale", []):
|
||||
# Handle shorthand format: { item: "ale", price: 2 }
|
||||
item_id = item_data.get("item_id") or item_data.get("item", "")
|
||||
inventory.append(NPCInventoryItem(
|
||||
item_id=item_id,
|
||||
price=item_data.get("price", 0),
|
||||
quantity=item_data.get("quantity"),
|
||||
))
|
||||
|
||||
# Parse dialogue hooks (optional)
|
||||
dialogue_hooks = None
|
||||
if data.get("dialogue_hooks"):
|
||||
hooks_data = data["dialogue_hooks"]
|
||||
dialogue_hooks = NPCDialogueHooks(
|
||||
greeting=hooks_data.get("greeting"),
|
||||
farewell=hooks_data.get("farewell"),
|
||||
busy=hooks_data.get("busy"),
|
||||
quest_complete=hooks_data.get("quest_complete"),
|
||||
)
|
||||
|
||||
return NPC(
|
||||
npc_id=data["npc_id"],
|
||||
name=data["name"],
|
||||
role=data["role"],
|
||||
location_id=data["location_id"],
|
||||
personality=personality,
|
||||
appearance=appearance,
|
||||
knowledge=knowledge,
|
||||
relationships=relationships,
|
||||
inventory_for_sale=inventory,
|
||||
dialogue_hooks=dialogue_hooks,
|
||||
quest_giver_for=data.get("quest_giver_for", []),
|
||||
reveals_locations=data.get("reveals_locations", []),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
def load_all_npcs(self) -> List[NPC]:
|
||||
"""
|
||||
Load all NPCs from all region directories.
|
||||
|
||||
Returns:
|
||||
List of NPC instances
|
||||
"""
|
||||
npcs = []
|
||||
|
||||
if not self.data_dir.exists():
|
||||
logger.error("NPC data directory does not exist", data_dir=str(self.data_dir))
|
||||
return npcs
|
||||
|
||||
for region_dir in self.data_dir.iterdir():
|
||||
if not region_dir.is_dir():
|
||||
continue
|
||||
|
||||
for file_path in region_dir.glob("*.yaml"):
|
||||
npc = self._load_npc_file(file_path)
|
||||
if npc:
|
||||
npcs.append(npc)
|
||||
|
||||
logger.info("All NPCs loaded", count=len(npcs))
|
||||
return npcs
|
||||
|
||||
def get_npcs_at_location(self, location_id: str) -> List[NPC]:
|
||||
"""
|
||||
Get all NPCs at a specific location.
|
||||
|
||||
Args:
|
||||
location_id: Location identifier
|
||||
|
||||
Returns:
|
||||
List of NPC instances at this location
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
npc_ids = self._location_npc_cache.get(location_id, [])
|
||||
return [
|
||||
self._npc_cache[npc_id]
|
||||
for npc_id in npc_ids
|
||||
if npc_id in self._npc_cache
|
||||
]
|
||||
|
||||
def get_npc_ids_at_location(self, location_id: str) -> List[str]:
|
||||
"""
|
||||
Get NPC IDs at a specific location.
|
||||
|
||||
Args:
|
||||
location_id: Location identifier
|
||||
|
||||
Returns:
|
||||
List of NPC IDs at this location
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return self._location_npc_cache.get(location_id, [])
|
||||
|
||||
def get_npcs_by_tag(self, tag: str) -> List[NPC]:
|
||||
"""
|
||||
Get all NPCs with a specific tag.
|
||||
|
||||
Args:
|
||||
tag: Tag to filter by (e.g., "merchant", "quest_giver")
|
||||
|
||||
Returns:
|
||||
List of NPC instances with this tag
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return [
|
||||
npc for npc in self._npc_cache.values()
|
||||
if tag in npc.tags
|
||||
]
|
||||
|
||||
def get_quest_givers(self, quest_id: str) -> List[NPC]:
|
||||
"""
|
||||
Get all NPCs that can give a specific quest.
|
||||
|
||||
Args:
|
||||
quest_id: Quest identifier
|
||||
|
||||
Returns:
|
||||
List of NPC instances that give this quest
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return [
|
||||
npc for npc in self._npc_cache.values()
|
||||
if quest_id in npc.quest_giver_for
|
||||
]
|
||||
|
||||
def get_all_npc_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available NPC IDs.
|
||||
|
||||
Returns:
|
||||
List of NPC IDs
|
||||
"""
|
||||
# Ensure all NPCs are loaded
|
||||
if not self._npc_cache:
|
||||
self.load_all_npcs()
|
||||
|
||||
return list(self._npc_cache.keys())
|
||||
|
||||
def reload_npc(self, npc_id: str) -> Optional[NPC]:
|
||||
"""
|
||||
Force reload an NPC from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when NPC definitions change.
|
||||
|
||||
Args:
|
||||
npc_id: Unique NPC identifier
|
||||
|
||||
Returns:
|
||||
NPC instance or None if not found
|
||||
"""
|
||||
# Remove from caches if present
|
||||
if npc_id in self._npc_cache:
|
||||
old_npc = self._npc_cache[npc_id]
|
||||
# Remove from location cache
|
||||
if old_npc.location_id in self._location_npc_cache:
|
||||
self._location_npc_cache[old_npc.location_id] = [
|
||||
n for n in self._location_npc_cache[old_npc.location_id]
|
||||
if n != npc_id
|
||||
]
|
||||
del self._npc_cache[npc_id]
|
||||
|
||||
return self.load_npc(npc_id)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data. Useful for testing."""
|
||||
self._npc_cache.clear()
|
||||
self._location_npc_cache.clear()
|
||||
logger.info("NPC cache cleared")
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_loader_instance: Optional[NPCLoader] = None
|
||||
|
||||
|
||||
def get_npc_loader() -> NPCLoader:
|
||||
"""
|
||||
Get the global NPCLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton NPCLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = NPCLoader()
|
||||
return _loader_instance
|
||||
236
api/app/services/origin_service.py
Normal file
236
api/app/services/origin_service.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
OriginService for loading character origin definitions from YAML files.
|
||||
|
||||
This service reads origin configuration and converts it into Origin
|
||||
dataclass instances, providing caching for performance.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.models.origins import Origin, StartingLocation, StartingBonus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OriginService:
|
||||
"""
|
||||
Loads character origin definitions from YAML configuration.
|
||||
|
||||
Origins define character backstories, starting locations, and narrative
|
||||
hooks that the AI DM uses to create personalized gameplay experiences.
|
||||
All origin definitions are stored in /app/data/origins.yaml.
|
||||
"""
|
||||
|
||||
def __init__(self, data_file: Optional[str] = None):
|
||||
"""
|
||||
Initialize the origin service.
|
||||
|
||||
Args:
|
||||
data_file: Path to origins YAML file.
|
||||
Defaults to /app/data/origins.yaml
|
||||
"""
|
||||
if data_file is None:
|
||||
# Default to app/data/origins.yaml relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_file = str(app_dir / "data" / "origins.yaml")
|
||||
|
||||
self.data_file = Path(data_file)
|
||||
self._origins_cache: Dict[str, Origin] = {}
|
||||
self._all_origins_loaded = False
|
||||
|
||||
logger.info("OriginService initialized", data_file=str(self.data_file))
|
||||
|
||||
def load_origin(self, origin_id: str) -> Optional[Origin]:
|
||||
"""
|
||||
Load a single origin by ID.
|
||||
|
||||
Args:
|
||||
origin_id: Unique origin identifier (e.g., "soul_revenant")
|
||||
|
||||
Returns:
|
||||
Origin instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if origin_id in self._origins_cache:
|
||||
logger.debug("Origin loaded from cache", origin_id=origin_id)
|
||||
return self._origins_cache[origin_id]
|
||||
|
||||
# Load all origins if not already loaded
|
||||
if not self._all_origins_loaded:
|
||||
self._load_all_origins()
|
||||
|
||||
# Return from cache after loading
|
||||
origin = self._origins_cache.get(origin_id)
|
||||
if origin:
|
||||
logger.info("Origin loaded successfully", origin_id=origin_id)
|
||||
else:
|
||||
logger.warning("Origin not found", origin_id=origin_id)
|
||||
|
||||
return origin
|
||||
|
||||
def load_all_origins(self) -> List[Origin]:
|
||||
"""
|
||||
Load all origins from the data file.
|
||||
|
||||
Returns:
|
||||
List of Origin instances
|
||||
"""
|
||||
if self._all_origins_loaded and self._origins_cache:
|
||||
logger.debug("All origins loaded from cache")
|
||||
return list(self._origins_cache.values())
|
||||
|
||||
return self._load_all_origins()
|
||||
|
||||
def _load_all_origins(self) -> List[Origin]:
|
||||
"""
|
||||
Internal method to load all origins from YAML.
|
||||
|
||||
Returns:
|
||||
List of Origin instances
|
||||
"""
|
||||
if not self.data_file.exists():
|
||||
logger.error("Origins data file does not exist", data_file=str(self.data_file))
|
||||
return []
|
||||
|
||||
try:
|
||||
# Load YAML file
|
||||
with open(self.data_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
origins_data = data.get("origins", {})
|
||||
origins = []
|
||||
|
||||
# Parse each origin
|
||||
for origin_id, origin_data in origins_data.items():
|
||||
try:
|
||||
origin = self._parse_origin_data(origin_id, origin_data)
|
||||
self._origins_cache[origin_id] = origin
|
||||
origins.append(origin)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse origin", origin_id=origin_id, error=str(e))
|
||||
continue
|
||||
|
||||
self._all_origins_loaded = True
|
||||
logger.info("All origins loaded successfully", count=len(origins))
|
||||
return origins
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load origins file", error=str(e))
|
||||
return []
|
||||
|
||||
def get_origin_by_id(self, origin_id: str) -> Optional[Origin]:
|
||||
"""
|
||||
Get an origin by ID (alias for load_origin).
|
||||
|
||||
Args:
|
||||
origin_id: Unique origin identifier
|
||||
|
||||
Returns:
|
||||
Origin instance or None if not found
|
||||
"""
|
||||
return self.load_origin(origin_id)
|
||||
|
||||
def get_all_origin_ids(self) -> List[str]:
|
||||
"""
|
||||
Get a list of all available origin IDs.
|
||||
|
||||
Returns:
|
||||
List of origin IDs (e.g., ["soul_revenant", "memory_thief"])
|
||||
"""
|
||||
if not self._all_origins_loaded:
|
||||
self._load_all_origins()
|
||||
|
||||
return list(self._origins_cache.keys())
|
||||
|
||||
def reload_origins(self) -> List[Origin]:
|
||||
"""
|
||||
Force reload all origins from disk, bypassing cache.
|
||||
|
||||
Useful for development/testing when origin definitions change.
|
||||
|
||||
Returns:
|
||||
List of Origin instances
|
||||
"""
|
||||
self.clear_cache()
|
||||
return self._load_all_origins()
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the origins cache. Useful for testing."""
|
||||
self._origins_cache.clear()
|
||||
self._all_origins_loaded = False
|
||||
logger.info("Origins cache cleared")
|
||||
|
||||
def _parse_origin_data(self, origin_id: str, data: Dict) -> Origin:
|
||||
"""
|
||||
Parse YAML data into an Origin dataclass.
|
||||
|
||||
Args:
|
||||
origin_id: The origin's unique identifier
|
||||
data: Dictionary loaded from YAML file
|
||||
|
||||
Returns:
|
||||
Origin instance
|
||||
|
||||
Raises:
|
||||
ValueError: If data is invalid or missing required fields
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["name", "description", "starting_location"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field in origin '{origin_id}': {field}")
|
||||
|
||||
# Parse starting location
|
||||
location_data = data["starting_location"]
|
||||
starting_location = StartingLocation(
|
||||
id=location_data.get("id", ""),
|
||||
name=location_data.get("name", ""),
|
||||
region=location_data.get("region", ""),
|
||||
description=location_data.get("description", "")
|
||||
)
|
||||
|
||||
# Parse starting bonus (optional)
|
||||
starting_bonus = None
|
||||
if "starting_bonus" in data:
|
||||
bonus_data = data["starting_bonus"]
|
||||
starting_bonus = StartingBonus(
|
||||
trait=bonus_data.get("trait", ""),
|
||||
description=bonus_data.get("description", ""),
|
||||
effect=bonus_data.get("effect", "")
|
||||
)
|
||||
|
||||
# Parse narrative hooks (optional)
|
||||
narrative_hooks = data.get("narrative_hooks", [])
|
||||
|
||||
# Create Origin instance
|
||||
origin = Origin(
|
||||
id=data.get("id", origin_id), # Use provided ID or fall back to key
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
starting_location=starting_location,
|
||||
narrative_hooks=narrative_hooks,
|
||||
starting_bonus=starting_bonus
|
||||
)
|
||||
|
||||
return origin
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[OriginService] = None
|
||||
|
||||
|
||||
def get_origin_service() -> OriginService:
|
||||
"""
|
||||
Get the global OriginService instance.
|
||||
|
||||
Returns:
|
||||
Singleton OriginService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = OriginService()
|
||||
return _service_instance
|
||||
373
api/app/services/outcome_service.py
Normal file
373
api/app/services/outcome_service.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Outcome determination service for Code of Conquest.
|
||||
|
||||
This service handles all code-determined game outcomes before they're passed
|
||||
to AI for narration. It uses the dice mechanics system to determine success/failure
|
||||
and selects appropriate rewards from loot tables.
|
||||
"""
|
||||
|
||||
import random
|
||||
import yaml
|
||||
import structlog
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from app.models.character import Character
|
||||
from app.game_logic.dice import (
|
||||
CheckResult, SkillType, Difficulty,
|
||||
skill_check, get_stat_for_skill, perception_check
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemFound:
|
||||
"""
|
||||
Represents an item found during a search.
|
||||
|
||||
Uses template key from generic_items.yaml.
|
||||
"""
|
||||
template_key: str
|
||||
name: str
|
||||
description: str
|
||||
value: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"template_key": self.template_key,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"value": self.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchOutcome:
|
||||
"""
|
||||
Complete result of a search action.
|
||||
|
||||
Includes the dice check result and any items/gold found.
|
||||
"""
|
||||
check_result: CheckResult
|
||||
items_found: List[ItemFound]
|
||||
gold_found: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"check_result": self.check_result.to_dict(),
|
||||
"items_found": [item.to_dict() for item in self.items_found],
|
||||
"gold_found": self.gold_found,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillCheckOutcome:
|
||||
"""
|
||||
Result of a generic skill check.
|
||||
|
||||
Used for persuasion, lockpicking, stealth, etc.
|
||||
"""
|
||||
check_result: CheckResult
|
||||
context: Dict[str, Any] # Additional context for AI narration
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for API response."""
|
||||
return {
|
||||
"check_result": self.check_result.to_dict(),
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
class OutcomeService:
|
||||
"""
|
||||
Service for determining game action outcomes.
|
||||
|
||||
Handles all dice rolls and loot selection before passing to AI.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the outcome service with loot tables and item templates."""
|
||||
self._loot_tables: Dict[str, Any] = {}
|
||||
self._item_templates: Dict[str, Any] = {}
|
||||
self._load_data()
|
||||
|
||||
def _load_data(self) -> None:
|
||||
"""Load loot tables and item templates from YAML files."""
|
||||
data_dir = Path(__file__).parent.parent / "data"
|
||||
|
||||
# Load loot tables
|
||||
loot_path = data_dir / "loot_tables.yaml"
|
||||
if loot_path.exists():
|
||||
with open(loot_path, "r") as f:
|
||||
self._loot_tables = yaml.safe_load(f)
|
||||
logger.info("loaded_loot_tables", count=len(self._loot_tables))
|
||||
else:
|
||||
logger.warning("loot_tables_not_found", path=str(loot_path))
|
||||
|
||||
# Load generic item templates
|
||||
items_path = data_dir / "generic_items.yaml"
|
||||
if items_path.exists():
|
||||
with open(items_path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
self._item_templates = data.get("templates", {})
|
||||
logger.info("loaded_item_templates", count=len(self._item_templates))
|
||||
else:
|
||||
logger.warning("item_templates_not_found", path=str(items_path))
|
||||
|
||||
def determine_search_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
location_type: str,
|
||||
dc: int = 12,
|
||||
bonus: int = 0
|
||||
) -> SearchOutcome:
|
||||
"""
|
||||
Determine the outcome of a search action.
|
||||
|
||||
Uses a perception check to determine success, then selects items
|
||||
from the appropriate loot table based on the roll margin.
|
||||
|
||||
Args:
|
||||
character: The character performing the search
|
||||
location_type: Type of location (forest, cave, town, etc.)
|
||||
dc: Difficulty class (default 12 = easy-medium)
|
||||
bonus: Additional bonus to the check
|
||||
|
||||
Returns:
|
||||
SearchOutcome with check result, items found, and gold found
|
||||
"""
|
||||
# Get character's effective wisdom for perception
|
||||
effective_stats = character.get_effective_stats()
|
||||
wisdom = effective_stats.wisdom
|
||||
|
||||
# Perform the perception check
|
||||
check_result = perception_check(wisdom, dc, bonus)
|
||||
|
||||
# Determine loot based on result
|
||||
items_found: List[ItemFound] = []
|
||||
gold_found: int = 0
|
||||
|
||||
if check_result.success:
|
||||
# Get loot table for this location (fall back to default)
|
||||
loot_table = self._loot_tables.get(
|
||||
location_type.lower(),
|
||||
self._loot_tables.get("default", {})
|
||||
)
|
||||
|
||||
# Select item rarity based on margin
|
||||
if check_result.margin >= 10:
|
||||
rarity = "rare"
|
||||
elif check_result.margin >= 5:
|
||||
rarity = "uncommon"
|
||||
else:
|
||||
rarity = "common"
|
||||
|
||||
# Get items for this rarity
|
||||
item_keys = loot_table.get(rarity, [])
|
||||
if item_keys:
|
||||
# Select 1-2 items based on margin
|
||||
num_items = 1 if check_result.margin < 8 else 2
|
||||
selected_keys = random.sample(
|
||||
item_keys,
|
||||
min(num_items, len(item_keys))
|
||||
)
|
||||
|
||||
for key in selected_keys:
|
||||
template = self._item_templates.get(key)
|
||||
if template:
|
||||
items_found.append(ItemFound(
|
||||
template_key=key,
|
||||
name=template.get("name", key.title()),
|
||||
description=template.get("description", ""),
|
||||
value=template.get("value", 1),
|
||||
))
|
||||
|
||||
# Calculate gold found
|
||||
gold_config = loot_table.get("gold", {})
|
||||
if gold_config:
|
||||
min_gold = gold_config.get("min", 0)
|
||||
max_gold = gold_config.get("max", 10)
|
||||
bonus_per_margin = gold_config.get("bonus_per_margin", 0)
|
||||
|
||||
base_gold = random.randint(min_gold, max_gold)
|
||||
margin_bonus = check_result.margin * bonus_per_margin
|
||||
gold_found = base_gold + margin_bonus
|
||||
|
||||
logger.info(
|
||||
"search_outcome_determined",
|
||||
character_id=character.character_id,
|
||||
location_type=location_type,
|
||||
dc=dc,
|
||||
success=check_result.success,
|
||||
margin=check_result.margin,
|
||||
items_count=len(items_found),
|
||||
gold_found=gold_found
|
||||
)
|
||||
|
||||
return SearchOutcome(
|
||||
check_result=check_result,
|
||||
items_found=items_found,
|
||||
gold_found=gold_found
|
||||
)
|
||||
|
||||
def determine_skill_check_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
skill_type: SkillType,
|
||||
dc: int,
|
||||
bonus: int = 0,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Determine the outcome of a generic skill check.
|
||||
|
||||
Args:
|
||||
character: The character performing the check
|
||||
skill_type: The type of skill check (PERSUASION, STEALTH, etc.)
|
||||
dc: Difficulty class to beat
|
||||
bonus: Additional bonus to the check
|
||||
context: Optional context for AI narration (e.g., NPC name, door type)
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome with check result and context
|
||||
"""
|
||||
# Get the appropriate stat for this skill
|
||||
stat_name = get_stat_for_skill(skill_type)
|
||||
effective_stats = character.get_effective_stats()
|
||||
stat_value = getattr(effective_stats, stat_name, 10)
|
||||
|
||||
# Perform the check
|
||||
check_result = skill_check(stat_value, dc, skill_type, bonus)
|
||||
|
||||
# Build outcome context
|
||||
outcome_context = context or {}
|
||||
outcome_context["skill_used"] = skill_type.name.lower()
|
||||
outcome_context["stat_used"] = stat_name
|
||||
|
||||
logger.info(
|
||||
"skill_check_outcome_determined",
|
||||
character_id=character.character_id,
|
||||
skill=skill_type.name,
|
||||
stat=stat_name,
|
||||
dc=dc,
|
||||
success=check_result.success,
|
||||
margin=check_result.margin
|
||||
)
|
||||
|
||||
return SkillCheckOutcome(
|
||||
check_result=check_result,
|
||||
context=outcome_context
|
||||
)
|
||||
|
||||
def determine_persuasion_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
dc: int,
|
||||
npc_name: Optional[str] = None,
|
||||
bonus: int = 0
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Convenience method for persuasion checks.
|
||||
|
||||
Args:
|
||||
character: The character attempting persuasion
|
||||
dc: Difficulty class based on NPC disposition
|
||||
npc_name: Name of the NPC being persuaded
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome
|
||||
"""
|
||||
context = {"npc_name": npc_name} if npc_name else {}
|
||||
return self.determine_skill_check_outcome(
|
||||
character,
|
||||
SkillType.PERSUASION,
|
||||
dc,
|
||||
bonus,
|
||||
context
|
||||
)
|
||||
|
||||
def determine_stealth_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
dc: int,
|
||||
situation: Optional[str] = None,
|
||||
bonus: int = 0
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Convenience method for stealth checks.
|
||||
|
||||
Args:
|
||||
character: The character attempting stealth
|
||||
dc: Difficulty class based on environment/observers
|
||||
situation: Description of what they're sneaking past
|
||||
bonus: Additional bonus
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome
|
||||
"""
|
||||
context = {"situation": situation} if situation else {}
|
||||
return self.determine_skill_check_outcome(
|
||||
character,
|
||||
SkillType.STEALTH,
|
||||
dc,
|
||||
bonus,
|
||||
context
|
||||
)
|
||||
|
||||
def determine_lockpicking_outcome(
|
||||
self,
|
||||
character: Character,
|
||||
dc: int,
|
||||
lock_description: Optional[str] = None,
|
||||
bonus: int = 0
|
||||
) -> SkillCheckOutcome:
|
||||
"""
|
||||
Convenience method for lockpicking checks.
|
||||
|
||||
Args:
|
||||
character: The character attempting to pick the lock
|
||||
dc: Difficulty class based on lock quality
|
||||
lock_description: Description of the lock/door
|
||||
bonus: Additional bonus (e.g., from thieves' tools)
|
||||
|
||||
Returns:
|
||||
SkillCheckOutcome
|
||||
"""
|
||||
context = {"lock_description": lock_description} if lock_description else {}
|
||||
return self.determine_skill_check_outcome(
|
||||
character,
|
||||
SkillType.LOCKPICKING,
|
||||
dc,
|
||||
bonus,
|
||||
context
|
||||
)
|
||||
|
||||
def get_dc_for_difficulty(self, difficulty: str) -> int:
|
||||
"""
|
||||
Get the DC value for a named difficulty.
|
||||
|
||||
Args:
|
||||
difficulty: Difficulty name (trivial, easy, medium, hard, very_hard)
|
||||
|
||||
Returns:
|
||||
DC value
|
||||
"""
|
||||
difficulty_map = {
|
||||
"trivial": Difficulty.TRIVIAL.value,
|
||||
"easy": Difficulty.EASY.value,
|
||||
"medium": Difficulty.MEDIUM.value,
|
||||
"hard": Difficulty.HARD.value,
|
||||
"very_hard": Difficulty.VERY_HARD.value,
|
||||
"nearly_impossible": Difficulty.NEARLY_IMPOSSIBLE.value,
|
||||
}
|
||||
return difficulty_map.get(difficulty.lower(), Difficulty.MEDIUM.value)
|
||||
|
||||
|
||||
# Global instance for use in API endpoints
|
||||
outcome_service = OutcomeService()
|
||||
602
api/app/services/rate_limiter_service.py
Normal file
602
api/app/services/rate_limiter_service.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""
|
||||
Rate Limiter Service
|
||||
|
||||
This module implements tier-based rate limiting for AI requests using Redis
|
||||
for distributed counting. Each user tier has a different daily limit for
|
||||
AI-generated turns.
|
||||
|
||||
Usage:
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.ai.model_selector import UserTier
|
||||
|
||||
# Initialize service
|
||||
rate_limiter = RateLimiterService()
|
||||
|
||||
# Check and increment usage
|
||||
try:
|
||||
rate_limiter.check_rate_limit("user_123", UserTier.FREE)
|
||||
rate_limiter.increment_usage("user_123")
|
||||
except RateLimitExceeded as e:
|
||||
print(f"Rate limit exceeded: {e}")
|
||||
|
||||
# Get remaining turns
|
||||
remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.services.redis_service import RedisService, RedisServiceError
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class RateLimitExceeded(Exception):
|
||||
"""
|
||||
Raised when a user has exceeded their daily rate limit.
|
||||
|
||||
Attributes:
|
||||
user_id: The user who exceeded the limit
|
||||
user_tier: The user's subscription tier
|
||||
limit: The daily limit for their tier
|
||||
current_usage: The current usage count
|
||||
reset_time: UTC timestamp when the limit resets
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
user_tier: UserTier,
|
||||
limit: int,
|
||||
current_usage: int,
|
||||
reset_time: datetime
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.user_tier = user_tier
|
||||
self.limit = limit
|
||||
self.current_usage = current_usage
|
||||
self.reset_time = reset_time
|
||||
|
||||
message = (
|
||||
f"Rate limit exceeded for user {user_id} ({user_tier.value} tier). "
|
||||
f"Used {current_usage}/{limit} turns. Resets at {reset_time.isoformat()}"
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RateLimiterService:
|
||||
"""
|
||||
Service for managing tier-based rate limiting.
|
||||
|
||||
This service uses Redis to track daily AI usage per user and enforces
|
||||
limits based on subscription tier. Counters reset daily at midnight UTC.
|
||||
|
||||
Tier Limits:
|
||||
- Free: 20 turns/day
|
||||
- Basic: 50 turns/day
|
||||
- Premium: 100 turns/day
|
||||
- Elite: 200 turns/day
|
||||
|
||||
Attributes:
|
||||
redis: RedisService instance for counter storage
|
||||
tier_limits: Mapping of tier to daily turn limit
|
||||
"""
|
||||
|
||||
# Daily turn limits per tier
|
||||
TIER_LIMITS = {
|
||||
UserTier.FREE: 20,
|
||||
UserTier.BASIC: 50,
|
||||
UserTier.PREMIUM: 100,
|
||||
UserTier.ELITE: 200,
|
||||
}
|
||||
|
||||
# Daily DM question limits per tier
|
||||
DM_QUESTION_LIMITS = {
|
||||
UserTier.FREE: 10,
|
||||
UserTier.BASIC: 20,
|
||||
UserTier.PREMIUM: 50,
|
||||
UserTier.ELITE: -1, # -1 means unlimited
|
||||
}
|
||||
|
||||
# Redis key prefix for rate limit counters
|
||||
KEY_PREFIX = "rate_limit:daily:"
|
||||
DM_QUESTION_PREFIX = "rate_limit:dm_questions:"
|
||||
|
||||
def __init__(self, redis_service: Optional[RedisService] = None):
|
||||
"""
|
||||
Initialize the rate limiter service.
|
||||
|
||||
Args:
|
||||
redis_service: Optional RedisService instance. If not provided,
|
||||
a new instance will be created.
|
||||
"""
|
||||
self.redis = redis_service or RedisService()
|
||||
|
||||
logger.info(
|
||||
"RateLimiterService initialized",
|
||||
tier_limits=self.TIER_LIMITS
|
||||
)
|
||||
|
||||
def _get_daily_key(self, user_id: str, day: Optional[date] = None) -> str:
|
||||
"""
|
||||
Generate the Redis key for a user's daily counter.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
day: The date (defaults to today UTC)
|
||||
|
||||
Returns:
|
||||
Redis key in format "rate_limit:daily:user_id:YYYY-MM-DD"
|
||||
"""
|
||||
if day is None:
|
||||
day = datetime.now(timezone.utc).date()
|
||||
|
||||
return f"{self.KEY_PREFIX}{user_id}:{day.isoformat()}"
|
||||
|
||||
def _get_seconds_until_midnight_utc(self) -> int:
|
||||
"""
|
||||
Calculate seconds remaining until midnight UTC.
|
||||
|
||||
Returns:
|
||||
Number of seconds until the next UTC midnight
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
tomorrow = datetime(
|
||||
now.year, now.month, now.day,
|
||||
tzinfo=timezone.utc
|
||||
) + timedelta(days=1)
|
||||
|
||||
return int((tomorrow - now).total_seconds())
|
||||
|
||||
def _get_reset_time(self) -> datetime:
|
||||
"""
|
||||
Get the UTC datetime when the rate limit resets.
|
||||
|
||||
Returns:
|
||||
Datetime of next midnight UTC
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
return datetime(
|
||||
now.year, now.month, now.day,
|
||||
tzinfo=timezone.utc
|
||||
) + timedelta(days=1)
|
||||
|
||||
def get_limit_for_tier(self, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the daily turn limit for a specific tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Daily turn limit for the tier
|
||||
"""
|
||||
return self.TIER_LIMITS.get(user_tier, self.TIER_LIMITS[UserTier.FREE])
|
||||
|
||||
def get_current_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the current daily usage count for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
|
||||
Returns:
|
||||
Current usage count (0 if no usage today)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_daily_key(user_id)
|
||||
|
||||
try:
|
||||
value = self.redis.get(key)
|
||||
usage = int(value) if value else 0
|
||||
|
||||
logger.debug(
|
||||
"Retrieved current usage",
|
||||
user_id=user_id,
|
||||
usage=usage
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(
|
||||
"Invalid usage value in Redis",
|
||||
user_id=user_id,
|
||||
error=str(e)
|
||||
)
|
||||
return 0
|
||||
|
||||
def check_rate_limit(self, user_id: str, user_tier: UserTier) -> None:
|
||||
"""
|
||||
Check if a user has exceeded their daily rate limit.
|
||||
|
||||
This method checks the current usage against the tier limit and
|
||||
raises an exception if the limit has been reached.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Raises:
|
||||
RateLimitExceeded: If the user has reached their daily limit
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
|
||||
if current_usage >= limit:
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
logger.warning(
|
||||
"Rate limit exceeded",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
reset_time=reset_time.isoformat()
|
||||
)
|
||||
|
||||
raise RateLimitExceeded(
|
||||
user_id=user_id,
|
||||
user_tier=user_tier,
|
||||
limit=limit,
|
||||
current_usage=current_usage,
|
||||
reset_time=reset_time
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Rate limit check passed",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
def increment_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Increment the daily usage counter for a user.
|
||||
|
||||
This method should be called after successfully processing an AI request.
|
||||
The counter will automatically expire at midnight UTC.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to increment
|
||||
|
||||
Returns:
|
||||
The new usage count after incrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_daily_key(user_id)
|
||||
|
||||
# Increment the counter
|
||||
new_count = self.redis.incr(key)
|
||||
|
||||
# Set expiration if this is the first increment (new_count == 1)
|
||||
# This ensures the key expires at midnight UTC
|
||||
if new_count == 1:
|
||||
ttl = self._get_seconds_until_midnight_utc()
|
||||
self.redis.expire(key, ttl)
|
||||
|
||||
logger.debug(
|
||||
"Set expiration on new rate limit key",
|
||||
user_id=user_id,
|
||||
ttl=ttl
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Incremented usage counter",
|
||||
user_id=user_id,
|
||||
new_count=new_count
|
||||
)
|
||||
|
||||
return new_count
|
||||
|
||||
def get_remaining_turns(self, user_id: str, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the number of remaining turns for a user today.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Number of turns remaining (0 if limit reached)
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
|
||||
remaining = max(0, limit - current_usage)
|
||||
|
||||
logger.debug(
|
||||
"Calculated remaining turns",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
remaining=remaining
|
||||
)
|
||||
|
||||
return remaining
|
||||
|
||||
def get_usage_info(self, user_id: str, user_tier: UserTier) -> dict:
|
||||
"""
|
||||
Get comprehensive usage information for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Dictionary with usage info:
|
||||
- user_id: User identifier
|
||||
- user_tier: Subscription tier
|
||||
- current_usage: Current daily usage
|
||||
- daily_limit: Daily limit for tier
|
||||
- remaining: Remaining turns
|
||||
- reset_time: ISO format UTC reset time
|
||||
- is_limited: Whether limit has been reached
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
remaining = max(0, limit - current_usage)
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
info = {
|
||||
"user_id": user_id,
|
||||
"user_tier": user_tier.value,
|
||||
"current_usage": current_usage,
|
||||
"daily_limit": limit,
|
||||
"remaining": remaining,
|
||||
"reset_time": reset_time.isoformat(),
|
||||
"is_limited": current_usage >= limit
|
||||
}
|
||||
|
||||
logger.debug("Retrieved usage info", **info)
|
||||
|
||||
return info
|
||||
|
||||
def reset_usage(self, user_id: str) -> bool:
|
||||
"""
|
||||
Reset the daily usage counter for a user.
|
||||
|
||||
This is primarily for admin/testing purposes.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to reset
|
||||
|
||||
Returns:
|
||||
True if the counter was deleted, False if it didn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_daily_key(user_id)
|
||||
deleted = self.redis.delete(key)
|
||||
|
||||
logger.info(
|
||||
"Reset usage counter",
|
||||
user_id=user_id,
|
||||
deleted=deleted > 0
|
||||
)
|
||||
|
||||
return deleted > 0
|
||||
|
||||
# ===== DM QUESTION RATE LIMITING =====
|
||||
|
||||
def _get_dm_question_key(self, user_id: str, day: Optional[date] = None) -> str:
|
||||
"""
|
||||
Generate the Redis key for a user's daily DM question counter.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
day: The date (defaults to today UTC)
|
||||
|
||||
Returns:
|
||||
Redis key in format "rate_limit:dm_questions:user_id:YYYY-MM-DD"
|
||||
"""
|
||||
if day is None:
|
||||
day = datetime.now(timezone.utc).date()
|
||||
|
||||
return f"{self.DM_QUESTION_PREFIX}{user_id}:{day.isoformat()}"
|
||||
|
||||
def get_dm_question_limit_for_tier(self, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the daily DM question limit for a specific tier.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Daily DM question limit for the tier (-1 for unlimited)
|
||||
"""
|
||||
return self.DM_QUESTION_LIMITS.get(user_tier, self.DM_QUESTION_LIMITS[UserTier.FREE])
|
||||
|
||||
def get_current_dm_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the current daily DM question usage count for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
|
||||
Returns:
|
||||
Current DM question usage count (0 if no usage today)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_dm_question_key(user_id)
|
||||
|
||||
try:
|
||||
value = self.redis.get(key)
|
||||
usage = int(value) if value else 0
|
||||
|
||||
logger.debug(
|
||||
"Retrieved current DM question usage",
|
||||
user_id=user_id,
|
||||
usage=usage
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(
|
||||
"Invalid DM question usage value in Redis",
|
||||
user_id=user_id,
|
||||
error=str(e)
|
||||
)
|
||||
return 0
|
||||
|
||||
def check_dm_question_limit(self, user_id: str, user_tier: UserTier) -> None:
|
||||
"""
|
||||
Check if a user has exceeded their daily DM question limit.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Raises:
|
||||
RateLimitExceeded: If the user has reached their daily DM question limit
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
limit = self.get_dm_question_limit_for_tier(user_tier)
|
||||
|
||||
# -1 means unlimited
|
||||
if limit == -1:
|
||||
logger.debug(
|
||||
"DM question limit check passed (unlimited)",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value
|
||||
)
|
||||
return
|
||||
|
||||
current_usage = self.get_current_dm_usage(user_id)
|
||||
|
||||
if current_usage >= limit:
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
logger.warning(
|
||||
"DM question limit exceeded",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
reset_time=reset_time.isoformat()
|
||||
)
|
||||
|
||||
raise RateLimitExceeded(
|
||||
user_id=user_id,
|
||||
user_tier=user_tier,
|
||||
limit=limit,
|
||||
current_usage=current_usage,
|
||||
reset_time=reset_time
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"DM question limit check passed",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
def increment_dm_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
Increment the daily DM question counter for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to increment
|
||||
|
||||
Returns:
|
||||
The new DM question usage count after incrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_dm_question_key(user_id)
|
||||
|
||||
# Increment the counter
|
||||
new_count = self.redis.incr(key)
|
||||
|
||||
# Set expiration if this is the first increment
|
||||
if new_count == 1:
|
||||
ttl = self._get_seconds_until_midnight_utc()
|
||||
self.redis.expire(key, ttl)
|
||||
|
||||
logger.debug(
|
||||
"Set expiration on new DM question key",
|
||||
user_id=user_id,
|
||||
ttl=ttl
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Incremented DM question counter",
|
||||
user_id=user_id,
|
||||
new_count=new_count
|
||||
)
|
||||
|
||||
return new_count
|
||||
|
||||
def get_remaining_dm_questions(self, user_id: str, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the number of remaining DM questions for a user today.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Number of DM questions remaining (-1 if unlimited, 0 if limit reached)
|
||||
"""
|
||||
limit = self.get_dm_question_limit_for_tier(user_tier)
|
||||
|
||||
# -1 means unlimited
|
||||
if limit == -1:
|
||||
return -1
|
||||
|
||||
current_usage = self.get_current_dm_usage(user_id)
|
||||
remaining = max(0, limit - current_usage)
|
||||
|
||||
logger.debug(
|
||||
"Calculated remaining DM questions",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value,
|
||||
current_usage=current_usage,
|
||||
limit=limit,
|
||||
remaining=remaining
|
||||
)
|
||||
|
||||
return remaining
|
||||
|
||||
def reset_dm_usage(self, user_id: str) -> bool:
|
||||
"""
|
||||
Reset the daily DM question counter for a user.
|
||||
|
||||
This is primarily for admin/testing purposes.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to reset
|
||||
|
||||
Returns:
|
||||
True if the counter was deleted, False if it didn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
key = self._get_dm_question_key(user_id)
|
||||
deleted = self.redis.delete(key)
|
||||
|
||||
logger.info(
|
||||
"Reset DM question counter",
|
||||
user_id=user_id,
|
||||
deleted=deleted > 0
|
||||
)
|
||||
|
||||
return deleted > 0
|
||||
505
api/app/services/redis_service.py
Normal file
505
api/app/services/redis_service.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Redis Service Wrapper
|
||||
|
||||
This module provides a wrapper around the redis-py client for handling caching,
|
||||
job queue data, and temporary storage. It provides connection pooling, automatic
|
||||
reconnection, and a clean interface for common Redis operations.
|
||||
|
||||
Usage:
|
||||
from app.services.redis_service import RedisService
|
||||
|
||||
# Initialize service
|
||||
redis = RedisService()
|
||||
|
||||
# Basic operations
|
||||
redis.set("key", "value", ttl=3600) # Set with 1 hour TTL
|
||||
value = redis.get("key")
|
||||
redis.delete("key")
|
||||
|
||||
# Health check
|
||||
if redis.health_check():
|
||||
print("Redis is healthy")
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, Any, Union
|
||||
|
||||
import redis
|
||||
from redis.exceptions import RedisError, ConnectionError as RedisConnectionError
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class RedisServiceError(Exception):
|
||||
"""Base exception for Redis service errors."""
|
||||
pass
|
||||
|
||||
|
||||
class RedisConnectionFailed(RedisServiceError):
|
||||
"""Raised when Redis connection cannot be established."""
|
||||
pass
|
||||
|
||||
|
||||
class RedisService:
|
||||
"""
|
||||
Service class for interacting with Redis.
|
||||
|
||||
This class provides:
|
||||
- Connection pooling for efficient connection management
|
||||
- Basic operations: get, set, delete, exists
|
||||
- TTL support for caching
|
||||
- Health check for monitoring
|
||||
- Automatic JSON serialization for complex objects
|
||||
|
||||
Attributes:
|
||||
pool: Redis connection pool
|
||||
client: Redis client instance
|
||||
"""
|
||||
|
||||
def __init__(self, redis_url: Optional[str] = None):
|
||||
"""
|
||||
Initialize the Redis service.
|
||||
|
||||
Reads configuration from environment variables if not provided:
|
||||
- REDIS_URL: Full Redis URL (e.g., redis://localhost:6379/0)
|
||||
|
||||
Args:
|
||||
redis_url: Optional Redis URL to override environment variable
|
||||
|
||||
Raises:
|
||||
RedisConnectionFailed: If connection to Redis fails
|
||||
"""
|
||||
self.redis_url = redis_url or os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
if not self.redis_url:
|
||||
logger.error("Missing Redis URL configuration")
|
||||
raise ValueError("Redis URL not configured. Set REDIS_URL environment variable.")
|
||||
|
||||
try:
|
||||
# Create connection pool for efficient connection management
|
||||
# Connection pooling allows multiple operations to share connections
|
||||
# and automatically manages connection lifecycle
|
||||
self.pool = redis.ConnectionPool.from_url(
|
||||
self.redis_url,
|
||||
max_connections=10,
|
||||
decode_responses=True, # Return strings instead of bytes
|
||||
socket_connect_timeout=5, # Connection timeout in seconds
|
||||
socket_timeout=5, # Operation timeout in seconds
|
||||
retry_on_timeout=True, # Retry on timeout
|
||||
)
|
||||
|
||||
# Create client using the connection pool
|
||||
self.client = redis.Redis(connection_pool=self.pool)
|
||||
|
||||
# Test connection
|
||||
self.client.ping()
|
||||
|
||||
logger.info("Redis service initialized", redis_url=self._sanitize_url(self.redis_url))
|
||||
|
||||
except RedisConnectionError as e:
|
||||
logger.error("Failed to connect to Redis", redis_url=self._sanitize_url(self.redis_url), error=str(e))
|
||||
raise RedisConnectionFailed(f"Could not connect to Redis at {self._sanitize_url(self.redis_url)}: {e}")
|
||||
except RedisError as e:
|
||||
logger.error("Redis initialization error", error=str(e))
|
||||
raise RedisServiceError(f"Redis initialization failed: {e}")
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get a value from Redis by key.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve
|
||||
|
||||
Returns:
|
||||
The value as string if found, None if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
value = self.client.get(key)
|
||||
|
||||
if value is not None:
|
||||
logger.debug("Redis GET", key=key, found=True)
|
||||
else:
|
||||
logger.debug("Redis GET", key=key, found=False)
|
||||
|
||||
return value
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis GET failed", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to get key '{key}': {e}")
|
||||
|
||||
def get_json(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a value from Redis and deserialize it from JSON.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve
|
||||
|
||||
Returns:
|
||||
The deserialized value if found, None if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or JSON is invalid
|
||||
"""
|
||||
value = self.get(key)
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to decode JSON from Redis", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to decode JSON for key '{key}': {e}")
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
ttl: Optional[int] = None,
|
||||
nx: bool = False,
|
||||
xx: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Set a value in Redis.
|
||||
|
||||
Args:
|
||||
key: The key to set
|
||||
value: The value to store (must be string)
|
||||
ttl: Time to live in seconds (None for no expiration)
|
||||
nx: Only set if key does not exist (for locking)
|
||||
xx: Only set if key already exists
|
||||
|
||||
Returns:
|
||||
True if the key was set, False if not set (due to nx/xx conditions)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
result = self.client.set(
|
||||
key,
|
||||
value,
|
||||
ex=ttl, # Expiration in seconds
|
||||
nx=nx, # Only set if not exists
|
||||
xx=xx # Only set if exists
|
||||
)
|
||||
|
||||
# set() returns True if set, None if not set due to nx/xx
|
||||
success = result is True or result == 1
|
||||
|
||||
logger.debug("Redis SET", key=key, ttl=ttl, nx=nx, xx=xx, success=success)
|
||||
|
||||
return success
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis SET failed", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to set key '{key}': {e}")
|
||||
|
||||
def set_json(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None,
|
||||
nx: bool = False,
|
||||
xx: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Serialize a value to JSON and store it in Redis.
|
||||
|
||||
Args:
|
||||
key: The key to set
|
||||
value: The value to serialize and store (must be JSON-serializable)
|
||||
ttl: Time to live in seconds (None for no expiration)
|
||||
nx: Only set if key does not exist
|
||||
xx: Only set if key already exists
|
||||
|
||||
Returns:
|
||||
True if the key was set, False if not set (due to nx/xx conditions)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or value is not JSON-serializable
|
||||
"""
|
||||
try:
|
||||
json_value = json.dumps(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error("Failed to serialize value to JSON", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to serialize value for key '{key}': {e}")
|
||||
|
||||
return self.set(key, json_value, ttl=ttl, nx=nx, xx=xx)
|
||||
|
||||
def delete(self, *keys: str) -> int:
|
||||
"""
|
||||
Delete one or more keys from Redis.
|
||||
|
||||
Args:
|
||||
*keys: One or more keys to delete
|
||||
|
||||
Returns:
|
||||
The number of keys that were deleted
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
if not keys:
|
||||
return 0
|
||||
|
||||
try:
|
||||
deleted_count = self.client.delete(*keys)
|
||||
|
||||
logger.debug("Redis DELETE", keys=keys, deleted_count=deleted_count)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis DELETE failed", keys=keys, error=str(e))
|
||||
raise RedisServiceError(f"Failed to delete keys {keys}: {e}")
|
||||
|
||||
def exists(self, *keys: str) -> int:
|
||||
"""
|
||||
Check if one or more keys exist in Redis.
|
||||
|
||||
Args:
|
||||
*keys: One or more keys to check
|
||||
|
||||
Returns:
|
||||
The number of keys that exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
if not keys:
|
||||
return 0
|
||||
|
||||
try:
|
||||
exists_count = self.client.exists(*keys)
|
||||
|
||||
logger.debug("Redis EXISTS", keys=keys, exists_count=exists_count)
|
||||
|
||||
return exists_count
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis EXISTS failed", keys=keys, error=str(e))
|
||||
raise RedisServiceError(f"Failed to check existence of keys {keys}: {e}")
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""
|
||||
Set a TTL (time to live) on an existing key.
|
||||
|
||||
Args:
|
||||
key: The key to set expiration on
|
||||
ttl: Time to live in seconds
|
||||
|
||||
Returns:
|
||||
True if the timeout was set, False if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
result = self.client.expire(key, ttl)
|
||||
|
||||
logger.debug("Redis EXPIRE", key=key, ttl=ttl, success=result)
|
||||
|
||||
return result
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis EXPIRE failed", key=key, ttl=ttl, error=str(e))
|
||||
raise RedisServiceError(f"Failed to set expiration for key '{key}': {e}")
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
"""
|
||||
Get the remaining TTL (time to live) for a key.
|
||||
|
||||
Args:
|
||||
key: The key to check
|
||||
|
||||
Returns:
|
||||
TTL in seconds, -1 if key exists but has no expiry, -2 if key doesn't exist
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
remaining = self.client.ttl(key)
|
||||
|
||||
logger.debug("Redis TTL", key=key, remaining=remaining)
|
||||
|
||||
return remaining
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis TTL failed", key=key, error=str(e))
|
||||
raise RedisServiceError(f"Failed to get TTL for key '{key}': {e}")
|
||||
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""
|
||||
Increment a key's value by the given amount.
|
||||
|
||||
If the key doesn't exist, it will be created with the increment value.
|
||||
|
||||
Args:
|
||||
key: The key to increment
|
||||
amount: Amount to increment by (default 1)
|
||||
|
||||
Returns:
|
||||
The new value after incrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or value is not an integer
|
||||
"""
|
||||
try:
|
||||
new_value = self.client.incrby(key, amount)
|
||||
|
||||
logger.debug("Redis INCR", key=key, amount=amount, new_value=new_value)
|
||||
|
||||
return new_value
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis INCR failed", key=key, amount=amount, error=str(e))
|
||||
raise RedisServiceError(f"Failed to increment key '{key}': {e}")
|
||||
|
||||
def decr(self, key: str, amount: int = 1) -> int:
|
||||
"""
|
||||
Decrement a key's value by the given amount.
|
||||
|
||||
If the key doesn't exist, it will be created with the negative increment value.
|
||||
|
||||
Args:
|
||||
key: The key to decrement
|
||||
amount: Amount to decrement by (default 1)
|
||||
|
||||
Returns:
|
||||
The new value after decrementing
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails or value is not an integer
|
||||
"""
|
||||
try:
|
||||
new_value = self.client.decrby(key, amount)
|
||||
|
||||
logger.debug("Redis DECR", key=key, amount=amount, new_value=new_value)
|
||||
|
||||
return new_value
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis DECR failed", key=key, amount=amount, error=str(e))
|
||||
raise RedisServiceError(f"Failed to decrement key '{key}': {e}")
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Redis connection is healthy.
|
||||
|
||||
This performs a PING command to verify the connection is working.
|
||||
|
||||
Returns:
|
||||
True if Redis is healthy and responding, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = self.client.ping()
|
||||
|
||||
if response:
|
||||
logger.debug("Redis health check passed")
|
||||
return True
|
||||
else:
|
||||
logger.warning("Redis health check failed - unexpected response", response=response)
|
||||
return False
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis health check failed", error=str(e))
|
||||
return False
|
||||
|
||||
def info(self) -> dict:
|
||||
"""
|
||||
Get Redis server information.
|
||||
|
||||
Returns:
|
||||
Dictionary containing server info (version, memory, clients, etc.)
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
info = self.client.info()
|
||||
|
||||
logger.debug("Redis INFO retrieved", redis_version=info.get('redis_version'))
|
||||
|
||||
return info
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis INFO failed", error=str(e))
|
||||
raise RedisServiceError(f"Failed to get Redis info: {e}")
|
||||
|
||||
def flush_db(self) -> bool:
|
||||
"""
|
||||
Delete all keys in the current database.
|
||||
|
||||
WARNING: This is a destructive operation. Use with caution.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
RedisServiceError: If the operation fails
|
||||
"""
|
||||
try:
|
||||
self.client.flushdb()
|
||||
|
||||
logger.warning("Redis database flushed")
|
||||
|
||||
return True
|
||||
|
||||
except RedisError as e:
|
||||
logger.error("Redis FLUSHDB failed", error=str(e))
|
||||
raise RedisServiceError(f"Failed to flush database: {e}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close all connections in the pool.
|
||||
|
||||
Call this when shutting down the application to cleanly release connections.
|
||||
"""
|
||||
try:
|
||||
self.pool.disconnect()
|
||||
logger.info("Redis connection pool closed")
|
||||
except Exception as e:
|
||||
logger.error("Error closing Redis connection pool", error=str(e))
|
||||
|
||||
def _sanitize_url(self, url: str) -> str:
|
||||
"""
|
||||
Remove password from Redis URL for safe logging.
|
||||
|
||||
Args:
|
||||
url: Redis URL that may contain password
|
||||
|
||||
Returns:
|
||||
URL with password masked
|
||||
"""
|
||||
# Simple sanitization - mask password if present
|
||||
# Format: redis://user:password@host:port/db
|
||||
if '@' in url:
|
||||
# Split on @ and mask everything before it except the protocol
|
||||
parts = url.split('@')
|
||||
protocol_and_creds = parts[0]
|
||||
host_and_rest = parts[1]
|
||||
|
||||
if '://' in protocol_and_creds:
|
||||
protocol = protocol_and_creds.split('://')[0]
|
||||
return f"{protocol}://***@{host_and_rest}"
|
||||
|
||||
return url
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - close connections."""
|
||||
self.close()
|
||||
return False
|
||||
705
api/app/services/session_service.py
Normal file
705
api/app/services/session_service.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Session Service - CRUD operations for game sessions.
|
||||
|
||||
This service handles creating, reading, updating, and managing game sessions,
|
||||
with support for both solo and multiplayer sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from appwrite.query import Query
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.models.session import GameSession, GameState, ConversationEntry, SessionConfig
|
||||
from app.models.enums import SessionStatus, SessionType
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.services.database_service import get_database_service
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Session limits per user
|
||||
MAX_ACTIVE_SESSIONS = 5
|
||||
|
||||
|
||||
class SessionNotFound(Exception):
|
||||
"""Raised when session ID doesn't exist or user doesn't own it."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionLimitExceeded(Exception):
|
||||
"""Raised when user tries to create more sessions than allowed."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionValidationError(Exception):
|
||||
"""Raised when session validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""
|
||||
Service for managing game sessions.
|
||||
|
||||
This service provides:
|
||||
- Session creation (solo and multiplayer)
|
||||
- Session retrieval and listing
|
||||
- Session state updates
|
||||
- Conversation history management
|
||||
- Game state tracking
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the session service with dependencies."""
|
||||
self.db = get_database_service()
|
||||
self.appwrite = AppwriteService()
|
||||
self.character_service = get_character_service()
|
||||
self.collection_id = "game_sessions"
|
||||
|
||||
logger.info("SessionService initialized")
|
||||
|
||||
def create_solo_session(
|
||||
self,
|
||||
user_id: str,
|
||||
character_id: str,
|
||||
starting_location: Optional[str] = None,
|
||||
starting_location_type: Optional[LocationType] = None
|
||||
) -> GameSession:
|
||||
"""
|
||||
Create a new solo game session.
|
||||
|
||||
This method:
|
||||
1. Validates user owns the character
|
||||
2. Validates user hasn't exceeded session limit
|
||||
3. Determines starting location from location data
|
||||
4. Creates session with initial game state
|
||||
5. Stores in Appwrite database
|
||||
|
||||
Args:
|
||||
user_id: Owner's user ID
|
||||
character_id: Character ID for this session
|
||||
starting_location: Initial location ID (optional, uses default starting location)
|
||||
starting_location_type: Initial location type (optional, derived from location data)
|
||||
|
||||
Returns:
|
||||
Created GameSession instance
|
||||
|
||||
Raises:
|
||||
CharacterNotFound: If character doesn't exist or user doesn't own it
|
||||
SessionLimitExceeded: If user has reached active session limit
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating solo session",
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
# Validate user owns the character
|
||||
character = self.character_service.get_character(character_id, user_id)
|
||||
if not character:
|
||||
raise CharacterNotFound(f"Character not found: {character_id}")
|
||||
|
||||
# Determine starting location from location data if not provided
|
||||
if not starting_location:
|
||||
location_loader = get_location_loader()
|
||||
starting_locations = location_loader.get_starting_locations()
|
||||
|
||||
if starting_locations:
|
||||
# Use first starting location (usually crossville_village)
|
||||
start_loc = starting_locations[0]
|
||||
starting_location = start_loc.location_id
|
||||
# Convert from enums.LocationType to action_prompt.LocationType via string value
|
||||
starting_location_type = LocationType(start_loc.location_type.value)
|
||||
logger.info("Using starting location from data",
|
||||
location_id=starting_location,
|
||||
location_type=starting_location_type.value)
|
||||
else:
|
||||
# Fallback to crossville_village
|
||||
starting_location = "crossville_village"
|
||||
starting_location_type = LocationType.TOWN
|
||||
logger.warning("No starting locations found, using fallback",
|
||||
location_id=starting_location)
|
||||
|
||||
# Ensure location type is set
|
||||
if not starting_location_type:
|
||||
starting_location_type = LocationType.TOWN
|
||||
|
||||
# Check session limit
|
||||
active_count = self.count_user_sessions(user_id, active_only=True)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id,
|
||||
current=active_count,
|
||||
limit=MAX_ACTIVE_SESSIONS)
|
||||
raise SessionLimitExceeded(
|
||||
f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). "
|
||||
f"Please end an existing session to start a new one."
|
||||
)
|
||||
|
||||
# Generate unique session ID
|
||||
session_id = ID.unique()
|
||||
|
||||
# Create game state with starting location
|
||||
game_state = GameState(
|
||||
current_location=starting_location,
|
||||
location_type=starting_location_type,
|
||||
discovered_locations=[starting_location],
|
||||
active_quests=[],
|
||||
world_events=[]
|
||||
)
|
||||
|
||||
# Create session instance
|
||||
session = GameSession(
|
||||
session_id=session_id,
|
||||
session_type=SessionType.SOLO,
|
||||
solo_character_id=character_id,
|
||||
user_id=user_id,
|
||||
party_member_ids=[],
|
||||
config=SessionConfig(),
|
||||
game_state=game_state,
|
||||
turn_order=[character_id],
|
||||
current_turn=0,
|
||||
turn_number=0,
|
||||
status=SessionStatus.ACTIVE
|
||||
)
|
||||
|
||||
# Serialize and store
|
||||
session_dict = session.to_dict()
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
document_data = {
|
||||
'userId': user_id,
|
||||
'characterId': character_id,
|
||||
'sessionData': session_json,
|
||||
'status': SessionStatus.ACTIVE.value,
|
||||
'sessionType': SessionType.SOLO.value
|
||||
}
|
||||
|
||||
self.db.create_document(
|
||||
collection_id=self.collection_id,
|
||||
data=document_data,
|
||||
document_id=session_id
|
||||
)
|
||||
|
||||
logger.info("Solo session created successfully",
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
return session
|
||||
|
||||
except (CharacterNotFound, SessionLimitExceeded):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to create solo session",
|
||||
user_id=user_id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_session(self, session_id: str, user_id: Optional[str] = None) -> GameSession:
|
||||
"""
|
||||
Get a session by ID.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
user_id: Optional user ID for ownership validation
|
||||
|
||||
Returns:
|
||||
GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching session", session_id=session_id)
|
||||
|
||||
# Get document from database
|
||||
document = self.db.get_row(self.collection_id, session_id)
|
||||
|
||||
if not document:
|
||||
logger.warning("Session not found", session_id=session_id)
|
||||
raise SessionNotFound(f"Session not found: {session_id}")
|
||||
|
||||
# Verify ownership if user_id provided
|
||||
if user_id and document.data.get('userId') != user_id:
|
||||
logger.warning("Session ownership mismatch",
|
||||
session_id=session_id,
|
||||
expected_user=user_id,
|
||||
actual_user=document.data.get('userId'))
|
||||
raise SessionNotFound(f"Session not found: {session_id}")
|
||||
|
||||
# Parse session data
|
||||
session_json = document.data.get('sessionData')
|
||||
session_dict = json.loads(session_json)
|
||||
session = GameSession.from_dict(session_dict)
|
||||
|
||||
logger.debug("Session fetched successfully", session_id=session_id)
|
||||
return session
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def update_session(self, session: GameSession) -> GameSession:
|
||||
"""
|
||||
Update a session in the database.
|
||||
|
||||
Args:
|
||||
session: GameSession instance with updated data
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Updating session", session_id=session.session_id)
|
||||
|
||||
# Serialize session
|
||||
session_dict = session.to_dict()
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
# Update in database
|
||||
self.db.update_document(
|
||||
collection_id=self.collection_id,
|
||||
document_id=session.session_id,
|
||||
data={
|
||||
'sessionData': session_json,
|
||||
'status': session.status.value
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Session updated successfully", session_id=session.session_id)
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update session",
|
||||
session_id=session.session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_user_sessions(
|
||||
self,
|
||||
user_id: str,
|
||||
active_only: bool = True,
|
||||
limit: int = 25
|
||||
) -> List[GameSession]:
|
||||
"""
|
||||
Get all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only return active sessions
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of GameSession instances
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching user sessions",
|
||||
user_id=user_id,
|
||||
active_only=active_only)
|
||||
|
||||
# Build query
|
||||
queries = [Query.equal('userId', user_id)]
|
||||
if active_only:
|
||||
queries.append(Query.equal('status', SessionStatus.ACTIVE.value))
|
||||
|
||||
documents = self.db.list_rows(
|
||||
table_id=self.collection_id,
|
||||
queries=queries,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# Parse all sessions
|
||||
sessions = []
|
||||
for document in documents:
|
||||
try:
|
||||
session_json = document.data.get('sessionData')
|
||||
session_dict = json.loads(session_json)
|
||||
session = GameSession.from_dict(session_dict)
|
||||
sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse session",
|
||||
document_id=document.id,
|
||||
error=str(e))
|
||||
continue
|
||||
|
||||
logger.debug("User sessions fetched",
|
||||
user_id=user_id,
|
||||
count=len(sessions))
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch user sessions",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def count_user_sessions(self, user_id: str, active_only: bool = True) -> int:
|
||||
"""
|
||||
Count sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only count active sessions
|
||||
|
||||
Returns:
|
||||
Number of sessions
|
||||
"""
|
||||
try:
|
||||
queries = [Query.equal('userId', user_id)]
|
||||
if active_only:
|
||||
queries.append(Query.equal('status', SessionStatus.ACTIVE.value))
|
||||
|
||||
count = self.db.count_documents(
|
||||
collection_id=self.collection_id,
|
||||
queries=queries
|
||||
)
|
||||
|
||||
logger.debug("Session count",
|
||||
user_id=user_id,
|
||||
active_only=active_only,
|
||||
count=count)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to count sessions",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
return 0
|
||||
|
||||
def end_session(self, session_id: str, user_id: str) -> GameSession:
|
||||
"""
|
||||
End a session by marking it as completed.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
user_id: User ID for ownership validation
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.info("Ending session", session_id=session_id, user_id=user_id)
|
||||
|
||||
session = self.get_session(session_id, user_id)
|
||||
session.status = SessionStatus.COMPLETED
|
||||
session.update_activity()
|
||||
|
||||
return self.update_session(session)
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to end session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_conversation_entry(
|
||||
self,
|
||||
session_id: str,
|
||||
character_id: str,
|
||||
character_name: str,
|
||||
action: str,
|
||||
dm_response: str,
|
||||
combat_log: Optional[List] = None,
|
||||
quest_offered: Optional[dict] = None
|
||||
) -> GameSession:
|
||||
"""
|
||||
Add an entry to the conversation history.
|
||||
|
||||
This method automatically:
|
||||
- Increments turn number
|
||||
- Adds timestamp
|
||||
- Updates last activity
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
character_id: Acting character's ID
|
||||
character_name: Acting character's name
|
||||
action: Player's action text
|
||||
dm_response: AI DM's response
|
||||
combat_log: Optional combat actions
|
||||
quest_offered: Optional quest offering info
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Adding conversation entry",
|
||||
session_id=session_id,
|
||||
character_id=character_id)
|
||||
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Create conversation entry
|
||||
entry = ConversationEntry(
|
||||
turn=session.turn_number + 1,
|
||||
character_id=character_id,
|
||||
character_name=character_name,
|
||||
action=action,
|
||||
dm_response=dm_response,
|
||||
combat_log=combat_log or [],
|
||||
quest_offered=quest_offered
|
||||
)
|
||||
|
||||
# Add entry and increment turn
|
||||
session.conversation_history.append(entry)
|
||||
session.turn_number += 1
|
||||
session.update_activity()
|
||||
|
||||
# Save to database
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add conversation entry",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_conversation_history(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0
|
||||
) -> List[ConversationEntry]:
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
limit: Maximum entries to return (None for all)
|
||||
offset: Number of entries to skip from end
|
||||
|
||||
Returns:
|
||||
List of ConversationEntry instances
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
history = session.conversation_history
|
||||
|
||||
# Apply offset (from end)
|
||||
if offset > 0:
|
||||
history = history[:-offset] if offset < len(history) else []
|
||||
|
||||
# Apply limit (from end)
|
||||
if limit and len(history) > limit:
|
||||
history = history[-limit:]
|
||||
|
||||
return history
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation history",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_recent_history(self, session_id: str, num_turns: int = 3) -> List[ConversationEntry]:
|
||||
"""
|
||||
Get the most recent conversation entries for AI context.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
num_turns: Number of recent turns to return
|
||||
|
||||
Returns:
|
||||
List of most recent ConversationEntry instances
|
||||
"""
|
||||
return self.get_conversation_history(session_id, limit=num_turns)
|
||||
|
||||
def update_location(
|
||||
self,
|
||||
session_id: str,
|
||||
new_location: str,
|
||||
location_type: LocationType
|
||||
) -> GameSession:
|
||||
"""
|
||||
Update the current location in the session.
|
||||
|
||||
Also adds location to discovered_locations if not already there.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
new_location: New location name
|
||||
location_type: New location type
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Updating location",
|
||||
session_id=session_id,
|
||||
new_location=new_location)
|
||||
|
||||
session = self.get_session(session_id)
|
||||
session.game_state.current_location = new_location
|
||||
session.game_state.location_type = location_type
|
||||
|
||||
# Track discovered locations
|
||||
if new_location not in session.game_state.discovered_locations:
|
||||
session.game_state.discovered_locations.append(new_location)
|
||||
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update location",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_discovered_location(self, session_id: str, location: str) -> GameSession:
|
||||
"""
|
||||
Add a location to the discovered locations list.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
location: Location name to add
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
if location not in session.game_state.discovered_locations:
|
||||
session.game_state.discovered_locations.append(location)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add discovered location",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_active_quest(self, session_id: str, quest_id: str) -> GameSession:
|
||||
"""
|
||||
Add a quest to the active quests list.
|
||||
|
||||
Validates max 2 active quests limit.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
quest_id: Quest ID to add
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionValidationError: If max quests limit exceeded
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Check max active quests (2)
|
||||
if len(session.game_state.active_quests) >= 2:
|
||||
raise SessionValidationError(
|
||||
"Maximum active quests reached (2/2). "
|
||||
"Complete or abandon a quest to accept a new one."
|
||||
)
|
||||
|
||||
if quest_id not in session.game_state.active_quests:
|
||||
session.game_state.active_quests.append(quest_id)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except SessionValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to add active quest",
|
||||
session_id=session_id,
|
||||
quest_id=quest_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def remove_active_quest(self, session_id: str, quest_id: str) -> GameSession:
|
||||
"""
|
||||
Remove a quest from the active quests list.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
quest_id: Quest ID to remove
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
if quest_id in session.game_state.active_quests:
|
||||
session.game_state.active_quests.remove(quest_id)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove active quest",
|
||||
session_id=session_id,
|
||||
quest_id=quest_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_world_event(self, session_id: str, event: dict) -> GameSession:
|
||||
"""
|
||||
Add a world event to the session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
event: Event dictionary with type, description, etc.
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Add timestamp if not present
|
||||
if 'timestamp' not in event:
|
||||
event['timestamp'] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
session.game_state.world_events.append(event)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add world event",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[SessionService] = None
|
||||
|
||||
|
||||
def get_session_service() -> SessionService:
|
||||
"""
|
||||
Get the global SessionService instance.
|
||||
|
||||
Returns:
|
||||
Singleton SessionService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = SessionService()
|
||||
return _service_instance
|
||||
528
api/app/services/usage_tracking_service.py
Normal file
528
api/app/services/usage_tracking_service.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Usage Tracking Service for AI cost and usage monitoring.
|
||||
|
||||
This service tracks all AI usage events, calculates costs, and provides
|
||||
analytics for monitoring and rate limiting purposes.
|
||||
|
||||
Usage:
|
||||
from app.services.usage_tracking_service import UsageTrackingService
|
||||
|
||||
tracker = UsageTrackingService()
|
||||
|
||||
# Log a usage event
|
||||
tracker.log_usage(
|
||||
user_id="user_123",
|
||||
model="anthropic/claude-3.5-sonnet",
|
||||
tokens_input=100,
|
||||
tokens_output=350,
|
||||
task_type=TaskType.STORY_PROGRESSION
|
||||
)
|
||||
|
||||
# Get daily usage
|
||||
usage = tracker.get_daily_usage("user_123", date.today())
|
||||
print(f"Total requests: {usage.total_requests}")
|
||||
print(f"Estimated cost: ${usage.estimated_cost:.4f}")
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from appwrite.client import Client
|
||||
from appwrite.services.tables_db import TablesDB
|
||||
from appwrite.exception import AppwriteException
|
||||
from appwrite.id import ID
|
||||
from appwrite.query import Query
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
from app.models.ai_usage import (
|
||||
AIUsageLog,
|
||||
DailyUsageSummary,
|
||||
MonthlyUsageSummary,
|
||||
TaskType
|
||||
)
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Cost per 1000 tokens by model (in USD)
|
||||
# These are estimates based on Replicate pricing
|
||||
MODEL_COSTS = {
|
||||
# Llama models (via Replicate) - very cheap
|
||||
"meta/meta-llama-3-8b-instruct": {
|
||||
"input": 0.0001, # $0.0001 per 1K input tokens
|
||||
"output": 0.0001, # $0.0001 per 1K output tokens
|
||||
},
|
||||
"meta/meta-llama-3-70b-instruct": {
|
||||
"input": 0.0006,
|
||||
"output": 0.0006,
|
||||
},
|
||||
# Claude models (via Replicate)
|
||||
"anthropic/claude-3.5-haiku": {
|
||||
"input": 0.001, # $0.001 per 1K input tokens
|
||||
"output": 0.005, # $0.005 per 1K output tokens
|
||||
},
|
||||
"anthropic/claude-3-haiku": {
|
||||
"input": 0.00025,
|
||||
"output": 0.00125,
|
||||
},
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"input": 0.003, # $0.003 per 1K input tokens
|
||||
"output": 0.015, # $0.015 per 1K output tokens
|
||||
},
|
||||
"anthropic/claude-4.5-sonnet": {
|
||||
"input": 0.003,
|
||||
"output": 0.015,
|
||||
},
|
||||
"anthropic/claude-3-opus": {
|
||||
"input": 0.015, # $0.015 per 1K input tokens
|
||||
"output": 0.075, # $0.075 per 1K output tokens
|
||||
},
|
||||
}
|
||||
|
||||
# Default cost for unknown models
|
||||
DEFAULT_COST = {"input": 0.001, "output": 0.005}
|
||||
|
||||
|
||||
class UsageTrackingService:
|
||||
"""
|
||||
Service for tracking AI usage and calculating costs.
|
||||
|
||||
This service provides:
|
||||
- Logging individual AI usage events to Appwrite
|
||||
- Calculating estimated costs based on model pricing
|
||||
- Retrieving daily and monthly usage summaries
|
||||
- Analytics for monitoring and rate limiting
|
||||
|
||||
The service stores usage logs in an Appwrite collection named 'ai_usage_logs'.
|
||||
"""
|
||||
|
||||
# Collection ID for usage logs
|
||||
COLLECTION_ID = "ai_usage_logs"
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the usage tracking service.
|
||||
|
||||
Reads configuration from environment variables:
|
||||
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
||||
- APPWRITE_PROJECT_ID: Appwrite project ID
|
||||
- APPWRITE_API_KEY: Appwrite API key
|
||||
- APPWRITE_DATABASE_ID: Appwrite database ID
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are missing
|
||||
"""
|
||||
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
||||
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
||||
self.api_key = os.getenv('APPWRITE_API_KEY')
|
||||
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
||||
|
||||
if not all([self.endpoint, self.project_id, self.api_key]):
|
||||
logger.error("Missing Appwrite configuration in environment variables")
|
||||
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
||||
|
||||
# Initialize Appwrite client
|
||||
self.client = Client()
|
||||
self.client.set_endpoint(self.endpoint)
|
||||
self.client.set_project(self.project_id)
|
||||
self.client.set_key(self.api_key)
|
||||
|
||||
# Initialize TablesDB service
|
||||
self.tables_db = TablesDB(self.client)
|
||||
|
||||
logger.info("UsageTrackingService initialized", database_id=self.database_id)
|
||||
|
||||
def log_usage(
|
||||
self,
|
||||
user_id: str,
|
||||
model: str,
|
||||
tokens_input: int,
|
||||
tokens_output: int,
|
||||
task_type: TaskType,
|
||||
session_id: Optional[str] = None,
|
||||
character_id: Optional[str] = None,
|
||||
request_duration_ms: int = 0,
|
||||
success: bool = True,
|
||||
error_message: Optional[str] = None
|
||||
) -> AIUsageLog:
|
||||
"""
|
||||
Log an AI usage event.
|
||||
|
||||
This method creates a new usage log entry in Appwrite with all
|
||||
relevant information about the AI request including calculated
|
||||
estimated cost.
|
||||
|
||||
Args:
|
||||
user_id: User who made the request
|
||||
model: Model identifier (e.g., "anthropic/claude-3.5-sonnet")
|
||||
tokens_input: Number of input tokens (prompt)
|
||||
tokens_output: Number of output tokens (response)
|
||||
task_type: Type of task (story, combat, quest, npc)
|
||||
session_id: Optional game session ID
|
||||
character_id: Optional character ID
|
||||
request_duration_ms: Request duration in milliseconds
|
||||
success: Whether the request succeeded
|
||||
error_message: Error message if failed
|
||||
|
||||
Returns:
|
||||
AIUsageLog with the logged data
|
||||
|
||||
Raises:
|
||||
AppwriteException: If storage fails
|
||||
"""
|
||||
# Calculate total tokens
|
||||
tokens_total = tokens_input + tokens_output
|
||||
|
||||
# Calculate estimated cost
|
||||
estimated_cost = self._calculate_cost(model, tokens_input, tokens_output)
|
||||
|
||||
# Generate log ID
|
||||
log_id = str(uuid4())
|
||||
|
||||
# Create usage log
|
||||
usage_log = AIUsageLog(
|
||||
log_id=log_id,
|
||||
user_id=user_id,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
model=model,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
tokens_total=tokens_total,
|
||||
estimated_cost=estimated_cost,
|
||||
task_type=task_type,
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
request_duration_ms=request_duration_ms,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
try:
|
||||
# Store in Appwrite
|
||||
result = self.tables_db.create_row(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
row_id=log_id,
|
||||
data=usage_log.to_dict()
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AI usage logged",
|
||||
log_id=log_id,
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
tokens_total=tokens_total,
|
||||
estimated_cost=estimated_cost,
|
||||
task_type=task_type.value,
|
||||
success=success
|
||||
)
|
||||
|
||||
return usage_log
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to log AI usage",
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_daily_usage(self, user_id: str, target_date: date) -> DailyUsageSummary:
|
||||
"""
|
||||
Get AI usage summary for a specific day.
|
||||
|
||||
Args:
|
||||
user_id: User ID to get usage for
|
||||
target_date: Date to get usage for
|
||||
|
||||
Returns:
|
||||
DailyUsageSummary with aggregated usage data
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
# Build date range for the target day (UTC)
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Query usage logs for this user and date
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
queries=[
|
||||
Query.equal("user_id", user_id),
|
||||
Query.greater_than_equal("timestamp", start_of_day.isoformat()),
|
||||
Query.less_than_equal("timestamp", end_of_day.isoformat()),
|
||||
Query.limit(1000) # Cap at 1000 entries per day
|
||||
]
|
||||
)
|
||||
|
||||
# Aggregate the data
|
||||
total_requests = 0
|
||||
total_tokens = 0
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
total_cost = 0.0
|
||||
requests_by_task: Dict[str, int] = {}
|
||||
|
||||
for doc in result['rows']:
|
||||
total_requests += 1
|
||||
total_tokens += doc.get('tokens_total', 0)
|
||||
total_input_tokens += doc.get('tokens_input', 0)
|
||||
total_output_tokens += doc.get('tokens_output', 0)
|
||||
total_cost += doc.get('estimated_cost', 0.0)
|
||||
|
||||
task_type = doc.get('task_type', 'general')
|
||||
requests_by_task[task_type] = requests_by_task.get(task_type, 0) + 1
|
||||
|
||||
summary = DailyUsageSummary(
|
||||
date=target_date,
|
||||
user_id=user_id,
|
||||
total_requests=total_requests,
|
||||
total_tokens=total_tokens,
|
||||
total_input_tokens=total_input_tokens,
|
||||
total_output_tokens=total_output_tokens,
|
||||
estimated_cost=total_cost,
|
||||
requests_by_task=requests_by_task
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Daily usage retrieved",
|
||||
user_id=user_id,
|
||||
date=target_date.isoformat(),
|
||||
total_requests=total_requests,
|
||||
estimated_cost=total_cost
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to get daily usage",
|
||||
user_id=user_id,
|
||||
date=target_date.isoformat(),
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_monthly_cost(self, user_id: str, year: int, month: int) -> MonthlyUsageSummary:
|
||||
"""
|
||||
Get AI usage cost summary for a specific month.
|
||||
|
||||
Args:
|
||||
user_id: User ID to get cost for
|
||||
year: Year (e.g., 2025)
|
||||
month: Month (1-12)
|
||||
|
||||
Returns:
|
||||
MonthlyUsageSummary with aggregated cost data
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
ValueError: If month is invalid
|
||||
"""
|
||||
if not 1 <= month <= 12:
|
||||
raise ValueError(f"Invalid month: {month}. Must be 1-12.")
|
||||
|
||||
try:
|
||||
# Build date range for the month
|
||||
start_of_month = datetime(year, month, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Calculate end of month
|
||||
if month == 12:
|
||||
end_of_month = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(seconds=1)
|
||||
else:
|
||||
end_of_month = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(seconds=1)
|
||||
|
||||
# Query usage logs for this user and month
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
queries=[
|
||||
Query.equal("user_id", user_id),
|
||||
Query.greater_than_equal("timestamp", start_of_month.isoformat()),
|
||||
Query.less_than_equal("timestamp", end_of_month.isoformat()),
|
||||
Query.limit(5000) # Cap at 5000 entries per month
|
||||
]
|
||||
)
|
||||
|
||||
# Aggregate the data
|
||||
total_requests = 0
|
||||
total_tokens = 0
|
||||
total_cost = 0.0
|
||||
|
||||
for doc in result['rows']:
|
||||
total_requests += 1
|
||||
total_tokens += doc.get('tokens_total', 0)
|
||||
total_cost += doc.get('estimated_cost', 0.0)
|
||||
|
||||
summary = MonthlyUsageSummary(
|
||||
year=year,
|
||||
month=month,
|
||||
user_id=user_id,
|
||||
total_requests=total_requests,
|
||||
total_tokens=total_tokens,
|
||||
estimated_cost=total_cost
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Monthly cost retrieved",
|
||||
user_id=user_id,
|
||||
year=year,
|
||||
month=month,
|
||||
total_requests=total_requests,
|
||||
estimated_cost=total_cost
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to get monthly cost",
|
||||
user_id=user_id,
|
||||
year=year,
|
||||
month=month,
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_total_daily_cost(self, target_date: date) -> float:
|
||||
"""
|
||||
Get the total AI cost across all users for a specific day.
|
||||
|
||||
Used for admin monitoring and alerting.
|
||||
|
||||
Args:
|
||||
target_date: Date to get cost for
|
||||
|
||||
Returns:
|
||||
Total estimated cost in USD
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
# Build date range for the target day
|
||||
start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Query all usage logs for this date
|
||||
result = self.tables_db.list_rows(
|
||||
database_id=self.database_id,
|
||||
table_id=self.COLLECTION_ID,
|
||||
queries=[
|
||||
Query.greater_than_equal("timestamp", start_of_day.isoformat()),
|
||||
Query.less_than_equal("timestamp", end_of_day.isoformat()),
|
||||
Query.limit(10000)
|
||||
]
|
||||
)
|
||||
|
||||
# Sum up costs
|
||||
total_cost = sum(doc.get('estimated_cost', 0.0) for doc in result['rows'])
|
||||
|
||||
logger.debug(
|
||||
"Total daily cost retrieved",
|
||||
date=target_date.isoformat(),
|
||||
total_cost=total_cost,
|
||||
total_documents=len(result['rows'])
|
||||
)
|
||||
|
||||
return total_cost
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error(
|
||||
"Failed to get total daily cost",
|
||||
date=target_date.isoformat(),
|
||||
error=str(e),
|
||||
code=e.code
|
||||
)
|
||||
raise
|
||||
|
||||
def get_user_request_count_today(self, user_id: str) -> int:
|
||||
"""
|
||||
Get the number of AI requests a user has made today.
|
||||
|
||||
Used for rate limiting checks.
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
|
||||
Returns:
|
||||
Number of requests made today
|
||||
|
||||
Raises:
|
||||
AppwriteException: If query fails
|
||||
"""
|
||||
try:
|
||||
summary = self.get_daily_usage(user_id, date.today())
|
||||
return summary.total_requests
|
||||
|
||||
except AppwriteException:
|
||||
# If there's an error, return 0 to be safe (fail open)
|
||||
logger.warning(
|
||||
"Failed to get user request count, returning 0",
|
||||
user_id=user_id
|
||||
)
|
||||
return 0
|
||||
|
||||
def _calculate_cost(self, model: str, tokens_input: int, tokens_output: int) -> float:
|
||||
"""
|
||||
Calculate the estimated cost for an AI request.
|
||||
|
||||
Args:
|
||||
model: Model identifier
|
||||
tokens_input: Number of input tokens
|
||||
tokens_output: Number of output tokens
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD
|
||||
"""
|
||||
# Get cost per 1K tokens for this model
|
||||
model_cost = MODEL_COSTS.get(model, DEFAULT_COST)
|
||||
|
||||
# Calculate cost (costs are per 1K tokens)
|
||||
input_cost = (tokens_input / 1000) * model_cost["input"]
|
||||
output_cost = (tokens_output / 1000) * model_cost["output"]
|
||||
total_cost = input_cost + output_cost
|
||||
|
||||
return round(total_cost, 6) # Round to 6 decimal places
|
||||
|
||||
@staticmethod
|
||||
def estimate_cost_for_model(model: str, tokens_input: int, tokens_output: int) -> float:
|
||||
"""
|
||||
Static method to estimate cost without needing a service instance.
|
||||
|
||||
Useful for pre-calculation and UI display.
|
||||
|
||||
Args:
|
||||
model: Model identifier
|
||||
tokens_input: Number of input tokens
|
||||
tokens_output: Number of output tokens
|
||||
|
||||
Returns:
|
||||
Estimated cost in USD
|
||||
"""
|
||||
model_cost = MODEL_COSTS.get(model, DEFAULT_COST)
|
||||
input_cost = (tokens_input / 1000) * model_cost["input"]
|
||||
output_cost = (tokens_output / 1000) * model_cost["output"]
|
||||
return round(input_cost + output_cost, 6)
|
||||
|
||||
@staticmethod
|
||||
def get_model_cost_info(model: str) -> Dict[str, float]:
|
||||
"""
|
||||
Get cost information for a model.
|
||||
|
||||
Args:
|
||||
model: Model identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with 'input' and 'output' cost per 1K tokens
|
||||
"""
|
||||
return MODEL_COSTS.get(model, DEFAULT_COST)
|
||||
156
api/app/tasks/__init__.py
Normal file
156
api/app/tasks/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
RQ Task Queue Configuration
|
||||
|
||||
This module defines the job queues used for background task processing.
|
||||
All async operations (AI generation, combat processing, marketplace tasks)
|
||||
are processed through these queues.
|
||||
|
||||
Queue Types:
|
||||
- ai_tasks: AI narrative generation (highest priority)
|
||||
- combat_tasks: Combat processing
|
||||
- marketplace_tasks: Auction cleanup and periodic tasks (lowest priority)
|
||||
|
||||
Usage:
|
||||
from app.tasks import get_queue, QUEUE_AI_TASKS
|
||||
|
||||
queue = get_queue(QUEUE_AI_TASKS)
|
||||
job = queue.enqueue(my_function, arg1, arg2)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from redis import Redis
|
||||
from rq import Queue
|
||||
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
# Queue names
|
||||
QUEUE_AI_TASKS = 'ai_tasks'
|
||||
QUEUE_COMBAT_TASKS = 'combat_tasks'
|
||||
QUEUE_MARKETPLACE_TASKS = 'marketplace_tasks'
|
||||
|
||||
# All queue names in priority order (highest first)
|
||||
ALL_QUEUES = [
|
||||
QUEUE_AI_TASKS,
|
||||
QUEUE_COMBAT_TASKS,
|
||||
QUEUE_MARKETPLACE_TASKS,
|
||||
]
|
||||
|
||||
# Queue configurations
|
||||
QUEUE_CONFIG = {
|
||||
QUEUE_AI_TASKS: {
|
||||
'default_timeout': 120, # 2 minutes for AI generation
|
||||
'default_result_ttl': 3600, # Keep results for 1 hour
|
||||
'default_failure_ttl': 86400, # Keep failures for 24 hours
|
||||
'description': 'AI narrative generation tasks',
|
||||
},
|
||||
QUEUE_COMBAT_TASKS: {
|
||||
'default_timeout': 60, # 1 minute for combat
|
||||
'default_result_ttl': 3600,
|
||||
'default_failure_ttl': 86400,
|
||||
'description': 'Combat processing tasks',
|
||||
},
|
||||
QUEUE_MARKETPLACE_TASKS: {
|
||||
'default_timeout': 300, # 5 minutes for marketplace
|
||||
'default_result_ttl': 3600,
|
||||
'default_failure_ttl': 86400,
|
||||
'description': 'Marketplace and auction tasks',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Redis connection singleton
|
||||
_redis_connection: Optional[Redis] = None
|
||||
|
||||
|
||||
def get_redis_connection() -> Redis:
|
||||
"""
|
||||
Get the Redis connection for RQ.
|
||||
|
||||
Uses a singleton pattern to reuse the connection.
|
||||
|
||||
Returns:
|
||||
Redis connection instance
|
||||
"""
|
||||
global _redis_connection
|
||||
|
||||
if _redis_connection is None:
|
||||
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
_redis_connection = Redis.from_url(redis_url)
|
||||
logger.info("RQ Redis connection established", redis_url=redis_url.split('@')[-1])
|
||||
|
||||
return _redis_connection
|
||||
|
||||
|
||||
def get_queue(queue_name: str) -> Queue:
|
||||
"""
|
||||
Get an RQ queue by name.
|
||||
|
||||
Args:
|
||||
queue_name: Name of the queue (use constants like QUEUE_AI_TASKS)
|
||||
|
||||
Returns:
|
||||
RQ Queue instance
|
||||
|
||||
Raises:
|
||||
ValueError: If queue name is not recognized
|
||||
"""
|
||||
if queue_name not in QUEUE_CONFIG:
|
||||
raise ValueError(f"Unknown queue: {queue_name}. Must be one of {list(QUEUE_CONFIG.keys())}")
|
||||
|
||||
config = QUEUE_CONFIG[queue_name]
|
||||
conn = get_redis_connection()
|
||||
|
||||
return Queue(
|
||||
name=queue_name,
|
||||
connection=conn,
|
||||
default_timeout=config['default_timeout'],
|
||||
)
|
||||
|
||||
|
||||
def get_all_queues() -> list[Queue]:
|
||||
"""
|
||||
Get all configured queues in priority order.
|
||||
|
||||
Returns:
|
||||
List of Queue instances (highest priority first)
|
||||
"""
|
||||
return [get_queue(name) for name in ALL_QUEUES]
|
||||
|
||||
|
||||
def get_queue_info(queue_name: str) -> dict:
|
||||
"""
|
||||
Get information about a queue.
|
||||
|
||||
Args:
|
||||
queue_name: Name of the queue
|
||||
|
||||
Returns:
|
||||
Dictionary with queue statistics
|
||||
"""
|
||||
queue = get_queue(queue_name)
|
||||
config = QUEUE_CONFIG[queue_name]
|
||||
|
||||
return {
|
||||
'name': queue_name,
|
||||
'description': config['description'],
|
||||
'count': len(queue),
|
||||
'default_timeout': config['default_timeout'],
|
||||
'default_result_ttl': config['default_result_ttl'],
|
||||
}
|
||||
|
||||
|
||||
def get_all_queues_info() -> list[dict]:
|
||||
"""
|
||||
Get information about all queues.
|
||||
|
||||
Returns:
|
||||
List of queue info dictionaries
|
||||
"""
|
||||
return [get_queue_info(name) for name in ALL_QUEUES]
|
||||
1314
api/app/tasks/ai_tasks.py
Normal file
1314
api/app/tasks/ai_tasks.py
Normal file
File diff suppressed because it is too large
Load Diff
1
api/app/utils/__init__.py
Normal file
1
api/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility modules for Code of Conquest."""
|
||||
444
api/app/utils/auth.py
Normal file
444
api/app/utils/auth.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Authentication Utilities
|
||||
|
||||
This module provides authentication middleware, decorators, and helper functions
|
||||
for protecting routes and managing user sessions.
|
||||
|
||||
Usage:
|
||||
from app.utils.auth import require_auth, require_tier, get_current_user
|
||||
|
||||
@app.route('/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
user = get_current_user()
|
||||
return f"Hello, {user.name}!"
|
||||
|
||||
@app.route('/premium-feature')
|
||||
@require_auth
|
||||
@require_tier('premium')
|
||||
def premium_feature():
|
||||
return "Premium content"
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional, Callable
|
||||
from flask import request, g, jsonify, redirect, url_for
|
||||
|
||||
from app.services.appwrite_service import AppwriteService, UserData
|
||||
from app.utils.response import unauthorized_response, forbidden_response
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
def extract_session_token() -> Optional[str]:
|
||||
"""
|
||||
Extract the session token from the request cookie.
|
||||
|
||||
Returns:
|
||||
Session token string if found, None otherwise
|
||||
"""
|
||||
config = get_config()
|
||||
cookie_name = config.auth.cookie_name
|
||||
|
||||
token = request.cookies.get(cookie_name)
|
||||
return token
|
||||
|
||||
|
||||
def verify_session(token: str) -> Optional[UserData]:
|
||||
"""
|
||||
Verify a session token and return the associated user data.
|
||||
|
||||
This function:
|
||||
1. Validates the session token with Appwrite
|
||||
2. Checks if the session is still active (not expired)
|
||||
3. Retrieves and returns the user data
|
||||
|
||||
Args:
|
||||
token: Session token from cookie
|
||||
|
||||
Returns:
|
||||
UserData object if session is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
appwrite = AppwriteService()
|
||||
|
||||
# Validate session
|
||||
session_data = appwrite.get_session(session_id=token)
|
||||
|
||||
# Get user data
|
||||
user_data = appwrite.get_user(user_id=session_data.user_id)
|
||||
return user_data
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.warning("Session verification failed", error=str(e), code=e.code)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during session verification", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user() -> Optional[UserData]:
|
||||
"""
|
||||
Get the current authenticated user from the request context.
|
||||
|
||||
This function retrieves the user object that was attached to the
|
||||
request context by the @require_auth decorator.
|
||||
|
||||
Returns:
|
||||
UserData object if user is authenticated, None otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/profile')
|
||||
@require_auth
|
||||
def profile():
|
||||
user = get_current_user()
|
||||
return f"Welcome, {user.name}!"
|
||||
"""
|
||||
return getattr(g, 'current_user', None)
|
||||
|
||||
|
||||
def require_auth(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication for a route (API endpoints).
|
||||
|
||||
This decorator:
|
||||
1. Extracts the session token from the cookie
|
||||
2. Verifies the session with Appwrite
|
||||
3. Attaches the user object to the request context (g.current_user)
|
||||
4. Allows the request to proceed if authenticated
|
||||
5. Returns 401 Unauthorized JSON if not authenticated
|
||||
|
||||
For web views, use @require_auth_web instead.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with authentication check
|
||||
|
||||
Usage:
|
||||
@app.route('/api/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
user = get_current_user()
|
||||
return f"Hello, {user.name}!"
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("Authentication required but no session token provided", path=request.path)
|
||||
return unauthorized_response(message="Authentication required. Please log in.")
|
||||
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if not user:
|
||||
logger.warning("Invalid or expired session token", path=request.path)
|
||||
return unauthorized_response(message="Session invalid or expired. Please log in again.")
|
||||
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
|
||||
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_auth_web(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication for a web view route.
|
||||
|
||||
This decorator:
|
||||
1. Extracts the session token from the cookie
|
||||
2. Verifies the session with Appwrite
|
||||
3. Attaches the user object to the request context (g.current_user)
|
||||
4. Allows the request to proceed if authenticated
|
||||
5. Redirects to login page if not authenticated
|
||||
|
||||
For API endpoints, use @require_auth instead.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with authentication check
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_auth_web
|
||||
def dashboard():
|
||||
user = get_current_user()
|
||||
return render_template('dashboard.html', user=user)
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("Authentication required but no session token provided", path=request.path)
|
||||
return redirect(url_for('auth_views.login'))
|
||||
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if not user:
|
||||
logger.warning("Invalid or expired session token", path=request.path)
|
||||
return redirect(url_for('auth_views.login'))
|
||||
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
|
||||
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_tier(minimum_tier: str) -> Callable:
|
||||
"""
|
||||
Decorator to require a minimum subscription tier for a route.
|
||||
|
||||
This decorator must be used AFTER @require_auth.
|
||||
|
||||
Tier hierarchy (from lowest to highest):
|
||||
- free
|
||||
- basic
|
||||
- premium
|
||||
- elite
|
||||
|
||||
Args:
|
||||
minimum_tier: Minimum required tier (free, basic, premium, elite)
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
|
||||
Raises:
|
||||
ValueError: If minimum_tier is invalid
|
||||
|
||||
Usage:
|
||||
@app.route('/premium-feature')
|
||||
@require_auth
|
||||
@require_tier('premium')
|
||||
def premium_feature():
|
||||
return "Premium content"
|
||||
"""
|
||||
# Define tier hierarchy
|
||||
tier_hierarchy = {
|
||||
'free': 0,
|
||||
'basic': 1,
|
||||
'premium': 2,
|
||||
'elite': 3
|
||||
}
|
||||
|
||||
if minimum_tier not in tier_hierarchy:
|
||||
raise ValueError(f"Invalid tier: {minimum_tier}. Must be one of {list(tier_hierarchy.keys())}")
|
||||
|
||||
def decorator(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (set by @require_auth)
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
logger.error("require_tier used without require_auth", path=request.path)
|
||||
return unauthorized_response(message="Authentication required.")
|
||||
|
||||
# Get user's tier level
|
||||
user_tier = user.tier
|
||||
user_tier_level = tier_hierarchy.get(user_tier, 0)
|
||||
required_tier_level = tier_hierarchy[minimum_tier]
|
||||
|
||||
# Check if user has sufficient tier
|
||||
if user_tier_level < required_tier_level:
|
||||
logger.warning(
|
||||
"Access denied - insufficient tier",
|
||||
user_id=user.id,
|
||||
user_tier=user_tier,
|
||||
required_tier=minimum_tier,
|
||||
path=request.path
|
||||
)
|
||||
return forbidden_response(
|
||||
message=f"This feature requires {minimum_tier.capitalize()} tier or higher. "
|
||||
f"Your current tier: {user_tier.capitalize()}."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Tier requirement met",
|
||||
user_id=user.id,
|
||||
user_tier=user_tier,
|
||||
required_tier=minimum_tier,
|
||||
path=request.path
|
||||
)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_email_verified(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require email verification for a route.
|
||||
|
||||
This decorator must be used AFTER @require_auth.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with email verification check
|
||||
|
||||
Usage:
|
||||
@app.route('/verified-only')
|
||||
@require_auth
|
||||
@require_email_verified
|
||||
def verified_only():
|
||||
return "You can only see this if your email is verified"
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (set by @require_auth)
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
logger.error("require_email_verified used without require_auth", path=request.path)
|
||||
return unauthorized_response(message="Authentication required.")
|
||||
|
||||
# Check if email is verified
|
||||
if not user.email_verified:
|
||||
logger.warning(
|
||||
"Access denied - email not verified",
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
path=request.path
|
||||
)
|
||||
return forbidden_response(
|
||||
message="Email verification required. Please check your inbox and verify your email address."
|
||||
)
|
||||
|
||||
logger.debug("Email verification confirmed", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def optional_auth(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator for routes that optionally use authentication.
|
||||
|
||||
This decorator will attach the user to g.current_user if authenticated,
|
||||
but will NOT block the request if not authenticated. Use this for routes
|
||||
that should behave differently based on authentication status.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with optional authentication
|
||||
|
||||
Usage:
|
||||
@app.route('/landing')
|
||||
@optional_auth
|
||||
def landing():
|
||||
user = get_current_user()
|
||||
if user:
|
||||
return f"Welcome back, {user.name}!"
|
||||
else:
|
||||
return "Welcome! Please log in."
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if token:
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if user:
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
logger.debug("Optional auth - user authenticated", user_id=user.id, path=request.path)
|
||||
else:
|
||||
logger.debug("Optional auth - invalid session", path=request.path)
|
||||
else:
|
||||
logger.debug("Optional auth - no session token", path=request.path)
|
||||
|
||||
# Call the original function regardless of authentication
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_user_tier() -> str:
|
||||
"""
|
||||
Get the current user's tier.
|
||||
|
||||
Returns:
|
||||
Tier string (free, basic, premium, elite), defaults to 'free' if not authenticated
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_auth
|
||||
def dashboard():
|
||||
tier = get_user_tier()
|
||||
return f"Your tier: {tier}"
|
||||
"""
|
||||
user = get_current_user()
|
||||
if user:
|
||||
return user.tier
|
||||
return 'free'
|
||||
|
||||
|
||||
def is_tier_sufficient(required_tier: str) -> bool:
|
||||
"""
|
||||
Check if the current user's tier meets the requirement.
|
||||
|
||||
Args:
|
||||
required_tier: Required tier level
|
||||
|
||||
Returns:
|
||||
True if user's tier is sufficient, False otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/feature')
|
||||
@require_auth
|
||||
def feature():
|
||||
if is_tier_sufficient('premium'):
|
||||
return "Premium features enabled"
|
||||
else:
|
||||
return "Upgrade to premium for more features"
|
||||
"""
|
||||
tier_hierarchy = {
|
||||
'free': 0,
|
||||
'basic': 1,
|
||||
'premium': 2,
|
||||
'elite': 3
|
||||
}
|
||||
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user_tier_level = tier_hierarchy.get(user.tier, 0)
|
||||
required_tier_level = tier_hierarchy.get(required_tier, 0)
|
||||
|
||||
return user_tier_level >= required_tier_level
|
||||
272
api/app/utils/logging.py
Normal file
272
api/app/utils/logging.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Logging configuration for Code of Conquest.
|
||||
|
||||
Sets up structured logging using structlog with JSON output
|
||||
and context-aware logging throughout the application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from structlog.stdlib import LoggerFactory
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_level: str = "INFO",
|
||||
log_format: str = "json",
|
||||
log_file: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Configure structured logging for the application.
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
log_format: Output format ('json' or 'console')
|
||||
log_file: Optional path to log file
|
||||
|
||||
Example:
|
||||
>>> from app.utils.logging import setup_logging
|
||||
>>> setup_logging(log_level="DEBUG", log_format="json")
|
||||
>>> logger = structlog.get_logger(__name__)
|
||||
>>> logger.info("Application started", version="0.1.0")
|
||||
"""
|
||||
# Convert log level string to logging constant
|
||||
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
# Configure standard library logging
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=numeric_level,
|
||||
)
|
||||
|
||||
# Create logs directory if logging to file
|
||||
if log_file:
|
||||
log_path = Path(log_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Add file handler
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(numeric_level)
|
||||
logging.root.addHandler(file_handler)
|
||||
|
||||
# Configure structlog processors
|
||||
processors = [
|
||||
# Add log level to event dict
|
||||
structlog.stdlib.add_log_level,
|
||||
# Add logger name to event dict
|
||||
structlog.stdlib.add_logger_name,
|
||||
# Add timestamp
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
# Add stack info for exceptions
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
# Format exceptions
|
||||
structlog.processors.format_exc_info,
|
||||
# Clean up _record and _from_structlog keys
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
]
|
||||
|
||||
# Add format-specific processor
|
||||
if log_format == "json":
|
||||
# JSON output for production
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
# Console-friendly output for development
|
||||
processors.append(
|
||||
structlog.dev.ConsoleRenderer(
|
||||
colors=True,
|
||||
exception_formatter=structlog.dev.plain_traceback
|
||||
)
|
||||
)
|
||||
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
context_class=dict,
|
||||
logger_factory=LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""
|
||||
Get a configured logger instance.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
|
||||
Returns:
|
||||
BoundLogger: Configured structlog logger
|
||||
|
||||
Example:
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> logger.info("User logged in", user_id="123", email="user@example.com")
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
class LoggerMixin:
|
||||
"""
|
||||
Mixin class to add logging capabilities to any class.
|
||||
|
||||
Provides a `self.logger` attribute with context automatically
|
||||
bound to the class name.
|
||||
|
||||
Example:
|
||||
>>> class MyService(LoggerMixin):
|
||||
... def do_something(self, user_id):
|
||||
... self.logger.info("Doing something", user_id=user_id)
|
||||
"""
|
||||
|
||||
@property
|
||||
def logger(self) -> structlog.stdlib.BoundLogger:
|
||||
"""Get logger for this class."""
|
||||
if not hasattr(self, '_logger'):
|
||||
self._logger = get_logger(self.__class__.__name__)
|
||||
return self._logger
|
||||
|
||||
|
||||
# Common logging utilities
|
||||
|
||||
def log_function_call(logger: structlog.stdlib.BoundLogger):
|
||||
"""
|
||||
Decorator to log function calls with arguments and return values.
|
||||
|
||||
Args:
|
||||
logger: Logger instance to use
|
||||
|
||||
Example:
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> @log_function_call(logger)
|
||||
... def process_data(data_id):
|
||||
... return {"status": "processed"}
|
||||
"""
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
logger.debug(
|
||||
f"Calling {func.__name__}",
|
||||
function=func.__name__,
|
||||
args=args,
|
||||
kwargs=kwargs
|
||||
)
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
logger.debug(
|
||||
f"{func.__name__} completed",
|
||||
function=func.__name__,
|
||||
result=result
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{func.__name__} failed",
|
||||
function=func.__name__,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_ai_call(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
user_id: str,
|
||||
model: str,
|
||||
tier: str,
|
||||
tokens_used: int,
|
||||
cost_estimate: float,
|
||||
context_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Log AI API call for cost tracking and analytics.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
user_id: User making the request
|
||||
model: AI model used
|
||||
tier: Model tier (free, standard, premium)
|
||||
tokens_used: Number of tokens consumed
|
||||
cost_estimate: Estimated cost in USD
|
||||
context_type: Type of context (narrative, combat, etc.)
|
||||
"""
|
||||
logger.info(
|
||||
"AI call completed",
|
||||
event_type="ai_call",
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
tier=tier,
|
||||
tokens_used=tokens_used,
|
||||
cost_estimate=cost_estimate,
|
||||
context_type=context_type
|
||||
)
|
||||
|
||||
|
||||
def log_combat_action(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
session_id: str,
|
||||
character_id: str,
|
||||
action_type: str,
|
||||
target_id: Optional[str] = None,
|
||||
damage: Optional[int] = None,
|
||||
effects: Optional[list] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log combat action for analytics and debugging.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
session_id: Game session ID
|
||||
character_id: Acting character ID
|
||||
action_type: Type of action (attack, cast, item, defend)
|
||||
target_id: Target of action (if applicable)
|
||||
damage: Damage dealt (if applicable)
|
||||
effects: Effects applied (if applicable)
|
||||
"""
|
||||
logger.info(
|
||||
"Combat action executed",
|
||||
event_type="combat_action",
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
action_type=action_type,
|
||||
target_id=target_id,
|
||||
damage=damage,
|
||||
effects=effects
|
||||
)
|
||||
|
||||
|
||||
def log_marketplace_transaction(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
transaction_id: str,
|
||||
buyer_id: str,
|
||||
seller_id: str,
|
||||
item_id: str,
|
||||
price: int,
|
||||
transaction_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Log marketplace transaction for analytics and auditing.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
transaction_id: Transaction ID
|
||||
buyer_id: Buyer user ID
|
||||
seller_id: Seller user ID
|
||||
item_id: Item ID
|
||||
price: Transaction price
|
||||
transaction_type: Type of transaction
|
||||
"""
|
||||
logger.info(
|
||||
"Marketplace transaction",
|
||||
event_type="marketplace_transaction",
|
||||
transaction_id=transaction_id,
|
||||
buyer_id=buyer_id,
|
||||
seller_id=seller_id,
|
||||
item_id=item_id,
|
||||
price=price,
|
||||
transaction_type=transaction_type
|
||||
)
|
||||
337
api/app/utils/response.py
Normal file
337
api/app/utils/response.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
API response wrapper for Code of Conquest.
|
||||
|
||||
Provides standardized JSON response format for all API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from flask import jsonify, Response
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
def api_response(
|
||||
result: Any = None,
|
||||
status: int = 200,
|
||||
error: Optional[Dict[str, Any]] = None,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized API response.
|
||||
|
||||
Args:
|
||||
result: The response data (or None if error)
|
||||
status: HTTP status code
|
||||
error: Error information (dict with 'code', 'message', 'details')
|
||||
meta: Metadata (pagination, etc.)
|
||||
request_id: Optional request ID for tracking
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return api_response(
|
||||
... result={"user_id": "123"},
|
||||
... status=200
|
||||
... )
|
||||
|
||||
>>> return api_response(
|
||||
... error={
|
||||
... "code": "INVALID_INPUT",
|
||||
... "message": "Email is required",
|
||||
... "details": {"field": "email"}
|
||||
... },
|
||||
... status=400
|
||||
... )
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
response_data = {
|
||||
"app": config.app.name,
|
||||
"version": config.app.version,
|
||||
"status": status,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": request_id,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"meta": meta
|
||||
}
|
||||
|
||||
return jsonify(response_data), status
|
||||
|
||||
|
||||
def success_response(
|
||||
result: Any = None,
|
||||
status: int = 200,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a success response.
|
||||
|
||||
Args:
|
||||
result: The response data
|
||||
status: HTTP status code (default 200)
|
||||
meta: Optional metadata
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return success_response({"character_id": "123"})
|
||||
"""
|
||||
return api_response(
|
||||
result=result,
|
||||
status=status,
|
||||
meta=meta,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
code: str,
|
||||
message: str,
|
||||
status: int = 400,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create an error response.
|
||||
|
||||
Args:
|
||||
code: Error code (e.g., "INVALID_INPUT")
|
||||
message: Human-readable error message
|
||||
status: HTTP status code (default 400)
|
||||
details: Optional additional error details
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return error_response(
|
||||
... code="NOT_FOUND",
|
||||
... message="Character not found",
|
||||
... status=404
|
||||
... )
|
||||
"""
|
||||
error = {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": details or {}
|
||||
}
|
||||
|
||||
return api_response(
|
||||
error=error,
|
||||
status=status,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def created_response(
|
||||
result: Any = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a 201 Created response.
|
||||
|
||||
Args:
|
||||
result: The created resource data
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 201
|
||||
|
||||
Example:
|
||||
>>> return created_response({"character_id": "123"})
|
||||
"""
|
||||
return success_response(
|
||||
result=result,
|
||||
status=201,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def accepted_response(
|
||||
result: Any = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a 202 Accepted response (for async operations).
|
||||
|
||||
Args:
|
||||
result: Job information or status
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 202
|
||||
|
||||
Example:
|
||||
>>> return accepted_response({"job_id": "abc123"})
|
||||
"""
|
||||
return success_response(
|
||||
result=result,
|
||||
status=202,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def no_content_response(request_id: Optional[str] = None) -> Response:
|
||||
"""
|
||||
Create a 204 No Content response.
|
||||
|
||||
Args:
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 204
|
||||
|
||||
Example:
|
||||
>>> return no_content_response()
|
||||
"""
|
||||
return success_response(
|
||||
result=None,
|
||||
status=204,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def paginated_response(
|
||||
items: list,
|
||||
page: int,
|
||||
limit: int,
|
||||
total: int,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a paginated response.
|
||||
|
||||
Args:
|
||||
items: List of items for current page
|
||||
page: Current page number
|
||||
limit: Items per page
|
||||
total: Total number of items
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with pagination metadata
|
||||
|
||||
Example:
|
||||
>>> return paginated_response(
|
||||
... items=[{"id": "1"}, {"id": "2"}],
|
||||
... page=1,
|
||||
... limit=20,
|
||||
... total=100
|
||||
... )
|
||||
"""
|
||||
pages = (total + limit - 1) // limit # Ceiling division
|
||||
|
||||
meta = {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": pages
|
||||
}
|
||||
|
||||
return success_response(
|
||||
result=items,
|
||||
meta=meta,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
# Common error responses
|
||||
|
||||
def unauthorized_response(
|
||||
message: str = "Unauthorized",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""401 Unauthorized response."""
|
||||
return error_response(
|
||||
code="UNAUTHORIZED",
|
||||
message=message,
|
||||
status=401,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def forbidden_response(
|
||||
message: str = "Forbidden",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""403 Forbidden response."""
|
||||
return error_response(
|
||||
code="FORBIDDEN",
|
||||
message=message,
|
||||
status=403,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def not_found_response(
|
||||
message: str = "Resource not found",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""404 Not Found response."""
|
||||
return error_response(
|
||||
code="NOT_FOUND",
|
||||
message=message,
|
||||
status=404,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def validation_error_response(
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""400 Bad Request for validation errors."""
|
||||
return error_response(
|
||||
code="INVALID_INPUT",
|
||||
message=message,
|
||||
status=400,
|
||||
details=details,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def rate_limit_exceeded_response(
|
||||
message: str = "Rate limit exceeded",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""429 Too Many Requests response."""
|
||||
return error_response(
|
||||
code="RATE_LIMIT_EXCEEDED",
|
||||
message=message,
|
||||
status=429,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def internal_error_response(
|
||||
message: str = "Internal server error",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""500 Internal Server Error response."""
|
||||
return error_response(
|
||||
code="INTERNAL_ERROR",
|
||||
message=message,
|
||||
status=500,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def premium_required_response(
|
||||
message: str = "This feature requires a premium subscription",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""403 Forbidden for premium-only features."""
|
||||
return error_response(
|
||||
code="PREMIUM_REQUIRED",
|
||||
message=message,
|
||||
status=403,
|
||||
request_id=request_id
|
||||
)
|
||||
Reference in New Issue
Block a user