first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

64
api/.env.example Normal file
View File

@@ -0,0 +1,64 @@
# Flask Configuration
FLASK_ENV=development
FLASK_APP=app
SECRET_KEY=your-secret-key-here-change-in-production
# Application Configuration
APP_NAME=Code of Conquest
APP_VERSION=0.1.0
DEBUG=True
# Redis Configuration
REDIS_URL=redis://localhost:6379/0
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
# Appwrite Configuration
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=your-project-id-here
APPWRITE_API_KEY=your-api-key-here
APPWRITE_DATABASE_ID=main
# Required Appwrite Collections:
# - characters
# - game_sessions
# - ai_usage_logs (for usage tracking - Task 7.13)
# AI Configuration (Replicate API - all models)
# All AI models (Llama-3, Claude Haiku/Sonnet/Opus) are accessed via Replicate
REPLICATE_API_TOKEN=your-replicate-token-here
REPLICATE_MODEL=meta/meta-llama-3-8b-instruct
# Available models:
# - meta/meta-llama-3-8b-instruct (Free tier)
# - anthropic/claude-3-haiku (Basic tier)
# - anthropic/claude-3.5-sonnet (Premium tier)
# - anthropic/claude-3-opus (Elite tier)
# Logging Configuration
LOG_LEVEL=DEBUG
LOG_FORMAT=json
# Rate Limiting
RATE_LIMIT_ENABLED=True
RATE_LIMIT_STORAGE_URL=redis://localhost:6379/1
# CORS Configuration
CORS_ORIGINS=http://localhost:5000,http://127.0.0.1:5000
# Session Configuration
SESSION_TIMEOUT_MINUTES=30
AUTO_SAVE_INTERVAL=5
# AI Configuration
AI_DEFAULT_TIMEOUT=30
AI_MAX_RETRIES=3
AI_COST_ALERT_THRESHOLD=100.00
# Marketplace Configuration
MARKETPLACE_AUCTION_CHECK_INTERVAL=300
# Security
ALLOWED_HOSTS=localhost,127.0.0.1
# Testing (optional)
TESTING=False

366
api/CLAUDE.md Normal file
View File

@@ -0,0 +1,366 @@
# CLAUDE.md - API Backend
## Service Overview
**API Backend** for Code of Conquest - The single source of truth for all business logic, game mechanics, and data operations.
**Tech Stack:** Flask + Appwrite + RQ + Redis + Replicate API (Llama-3/Claude models)
**Port:** 5000 (development), 5000 (production internal)
**Location:** `/api`
---
## Architecture Role
This API backend is the **single source of truth** for all game logic:
- ✅ All business logic
- ✅ Data models and validation
- ✅ Database operations (Appwrite)
- ✅ Authentication & authorization
- ✅ Game mechanics calculations
- ✅ AI orchestration (all models via Replicate API)
- ✅ Background job processing (RQ)
**What this service does NOT do:**
- ❌ No UI rendering (that's `/public_web` and `/godot_client`)
- ❌ No direct user interaction (only via API endpoints)
---
## Documentation Index
**API Backend Documentation:**
- **[API_REFERENCE.md](docs/API_REFERENCE.md)** - API endpoints and response formats
- **[DATA_MODELS.md](docs/DATA_MODELS.md)** - Character system, items, skills, effects
- **[GAME_SYSTEMS.md](docs/GAME_SYSTEMS.md)** - Combat mechanics, marketplace, NPCs
- **[QUEST_SYSTEM.md](docs/QUEST_SYSTEM.md)** - Quest mechanics and data structures
- **[STORY_PROGRESSION.md](docs/STORY_PROGRESSION.md)** - Story progression system
- **[MULTIPLAYER.md](docs/MULTIPLAYER.md)** - Multiplayer session backend logic
- **[API_TESTING.md](docs/API_TESTING.md)** - API testing guide with examples
- **[APPWRITE_SETUP.md](docs/APPWRITE_SETUP.md)** - Database setup guide
- **[PHASE4_IMPLEMENTATION.md](docs/PHASE4_IMPLEMENTATION.md)** - Phase 4 detailed implementation tasks
**Project-Wide Documentation:**
- **[../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md)** - System architecture overview
- **[../docs/ROADMAP.md](../docs/ROADMAP.md)** - Development roadmap and progress tracking
- **[../docs/DEPLOYMENT.md](../docs/DEPLOYMENT.md)** - Deployment guide
- **[../docs/WEB_VS_CLIENT_SYSTEMS.md](../docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between frontends
**Documentation Hierarchy:**
- `/docs/` = Project-wide roadmap, architecture, and cross-service documentation
- `/api/docs/` = API-specific implementation details (endpoints, data models, game systems)
> **Note:** For overall project progress, always check `/docs/ROADMAP.md`. Service-specific docs contain implementation details.
---
## Development Guidelines
### Project Structure
```
api/
├── app/ # Application code
│ ├── api/ # API endpoint blueprints
│ ├── models/ # Data models (dataclasses)
│ ├── services/ # Business logic & integrations
│ ├── utils/ # Utilities
│ ├── tasks/ # Background jobs (RQ)
│ ├── ai/ # AI integration
│ ├── game_logic/ # Game mechanics
│ └── data/ # Game data (YAML)
├── config/ # Configuration files
├── tests/ # Test suite
├── scripts/ # Utility scripts
└── docs/ # API documentation
```
### Coding Standards
**Style & Structure**
- Prefer longer, explicit code over compact one-liners
- Always include docstrings for functions/classes + inline comments
- Strongly prefer OOP-style code (classes over functional/nested functions)
- Strong typing throughout (dataclasses, TypedDict, Enums, type hints)
- Value future-proofing and expanded usage insights
**Data Design**
- Use dataclasses for internal data modeling
- Typed JSON structures
- Functions return fully typed objects (no loose dicts)
- Snapshot files in JSON or YAML
- Human-readable fields (e.g., `scan_duration`)
**Logging**
- Use structlog (pip package)
- Setup logging at app start: `logger = structlog.get_logger(__file__)`
**Preferred Pip Packages**
- API/Web Server: Flask
- HTTP: Requests
- Logging: Structlog
- Job Queue: RQ
- Testing: Pytest
### API Design Standards
**RESTful Conventions:**
- Use proper HTTP methods (GET, POST, PUT, DELETE)
- Resource-based URLs: `/api/v1/characters`, not `/api/v1/get_character`
- Versioning: `/api/v1/...`
**Standardized Response Format:**
```json
{
"app": "Code of Conquest API",
"version": "0.1.0",
"status": 200,
"timestamp": "2025-01-15T10:30:00Z",
"request_id": "optional-request-id",
"result": {
"data": "..."
},
"error": null,
"meta": {}
}
```
**Error Responses:**
```json
{
"app": "Code of Conquest API",
"version": "0.1.0",
"status": 400,
"timestamp": "2025-01-15T10:30:00Z",
"result": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid character name",
"details": {
"field": "name",
"issue": "Name must be between 3 and 50 characters"
}
}
}
```
### Error Handling
- Custom exception classes for domain-specific errors
- Consistent error response formats (use `app.utils.response`)
- Logging severity levels (ERROR vs WARNING)
- Never expose internal errors to clients (sanitize stack traces)
### Security Standards
**Authentication & Authorization**
- Use Appwrite Auth for user management
- HTTP-only cookies for session storage
- Session validation on every protected API call
- User ID verification (users can only access their own data)
- Use `@require_auth` decorator for protected endpoints
**Input Validation**
- Validate ALL JSON payloads against schemas
- Sanitize user inputs (character names, chat messages)
- Prevent injection attacks (SQL, NoSQL, command injection)
- Use bleach for HTML sanitization
**Rate Limiting**
- AI endpoint limits based on subscription tier
- Use Flask-Limiter with Redis backend
- Configure limits in `config/*.yaml`
**API Security**
- CORS properly configured (only allow frontend domains)
- API keys stored in environment variables (never in code)
- Appwrite permissions set correctly on all collections
- HTTPS only in production
**Cost Control (AI)**
- Daily limits on AI calls per tier
- Max tokens per request type
- Cost logging for analytics and alerts
- Graceful degradation if limits exceeded
### Configuration
- Environment-specific configs in `/config/*.yaml`
- `development.yaml` - Local dev settings
- `production.yaml` - Production settings
- `.env` for secrets (never committed)
- Maintain `.env.example` for documentation
- Typed config loaders using dataclasses
- Validation on startup
### Testing Standards
**Unit Tests (Pytest):**
- Test all models, services, game logic
- Test files in `/tests`
- Run with: `pytest`
- Coverage: `pytest --cov=app tests/`
**Integration Tests:**
- Test API endpoints end-to-end
- Use test database/fixtures
- Example: `tests/test_api_characters_integration.py`
**API Testing:**
- Maintain `docs/API_TESTING.md` with curl/httpie examples
- Document expected responses
- Test all endpoints manually before commit
### Dependency Management
- Use `requirements.txt` in `/api` directory
- Use virtual environment: `python3 -m venv venv`
- Pin versions to version ranges
- Activate venv before running: `source venv/bin/activate`
### Background Jobs (RQ)
**Queue Types:**
- `ai_tasks` - AI narrative generation
- `combat_tasks` - Combat processing
- `marketplace_tasks` - Auction cleanup, periodic tasks
**Job Implementation:**
- Define jobs in `app/tasks/`
- Use `@job` decorator from RQ
- Jobs should be idempotent (safe to retry)
- Log job start, completion, errors
**Running Workers:**
```bash
rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379
```
---
## Workflow for API Development
When implementing new API features:
1. **Start with models** - Define dataclasses in `app/models/`
2. **Write tests** - TDD approach (test first, then implement)
3. **Implement service** - Business logic in `app/services/`
4. **Create endpoint** - API blueprint in `app/api/`
5. **Test manually** - Use curl/httpie, update `docs/API_TESTING.md`
6. **Security review** - Check auth, validation, rate limiting
7. **Document** - Update `docs/API_REFERENCE.md`
**Example Flow:**
```bash
# 1. Create model
# app/models/quest.py - Define Quest dataclass
# 2. Write test
# tests/test_quest.py - Test quest creation, validation
# 3. Implement service
# app/services/quest_service.py - Quest CRUD operations
# 4. Create endpoint
# app/api/quests.py - REST endpoints
# 5. Test
curl -X POST http://localhost:5000/api/v1/quests \
-H "Content-Type: application/json" \
-d '{"title": "Find the Ancient Relic"}'
# 6. Document
# Update docs/API_REFERENCE.md and docs/API_TESTING.md
```
---
## Running the API Backend
### Development
**Prerequisites:**
- Python 3.11+
- Redis running (via Docker Compose)
- Appwrite instance configured
**Setup:**
```bash
cd api
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Edit .env with your credentials
```
**Initialize Database:**
```bash
python scripts/init_database.py
```
**Run Development Server:**
```bash
# Terminal 1: Redis
docker-compose up
# Terminal 2: API Server
source venv/bin/activate
export FLASK_ENV=development
python wsgi.py # → http://localhost:5000
# Terminal 3: RQ Worker (optional)
source venv/bin/activate
rq worker ai_tasks --url redis://localhost:6379
```
### Production
**Run with Gunicorn:**
```bash
gunicorn --bind 0.0.0.0:5000 --workers 4 wsgi:app
```
**Environment Variables:**
```
FLASK_ENV=production
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=...
APPWRITE_API_KEY=...
APPWRITE_DATABASE_ID=...
ANTHROPIC_API_KEY=...
REPLICATE_API_TOKEN=...
REDIS_URL=redis://redis:6379
SECRET_KEY=...
```
---
## Git Standards
**Commit Messages:**
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc.
- Examples:
- `feat(api): add quest endpoints`
- `fix(models): character stat calculation bug`
- `docs(api): update API_REFERENCE with quest endpoints`
**Branch Strategy:**
- Branch off `dev` for features
- Merge back to `dev` for testing
- Promote to `master` for production
---
## Notes for Claude Code
When working on the API backend:
1. **Business logic lives here** - This is the single source of truth
2. **No UI code** - Don't create templates or frontend code in this service
3. **Security first** - Validate inputs, check permissions, sanitize outputs
4. **Cost conscious** - Monitor AI usage, implement limits
5. **Test thoroughly** - Use pytest, write integration tests
6. **Document as you go** - Update API_REFERENCE.md and API_TESTING.md
7. **Think about frontends** - Design endpoints that work for both web and Godot clients
**Remember:**
- Developer has strong security expertise (don't compromise security for convenience)
- Developer has extensive infrastructure experience (focus on application logic)
- This API serves multiple frontends (web and Godot) - keep it generic

35
api/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# Code of Conquest API Backend
# Production-ready Dockerfile with Gunicorn
FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Set work directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 5000
# Run with Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "wsgi:app"]

204
api/README.md Normal file
View File

@@ -0,0 +1,204 @@
# Code of Conquest - API Backend
Flask-based REST API backend for Code of Conquest, an AI-powered D&D-style game.
## Overview
This is the **API backend** component of Code of Conquest. It provides:
- RESTful API endpoints for game functionality
- Business logic and game mechanics
- Database operations (Appwrite)
- AI integration (Claude, Replicate)
- Background job processing (RQ + Redis)
- Authentication and authorization
## Architecture
**Tech Stack:**
- **Framework:** Flask 3.x
- **Database:** Appwrite (NoSQL)
- **Job Queue:** RQ (Redis Queue)
- **Cache:** Redis
- **AI:** Anthropic Claude, Replicate
- **Logging:** Structlog
- **WSGI Server:** Gunicorn
**Key Components:**
- `/app/api` - API endpoint blueprints
- `/app/models` - Data models (dataclasses)
- `/app/services` - Business logic and external integrations
- `/app/utils` - Helper functions
- `/app/tasks` - Background job handlers
- `/app/data` - Game data (YAML files)
## Setup
### Prerequisites
- Python 3.11+
- Redis server
- Appwrite instance (cloud or self-hosted)
### Installation
1. Create virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Configure environment:
```bash
cp .env.example .env
# Edit .env with your credentials
```
4. Initialize database:
```bash
python scripts/init_database.py
```
### Running Locally
**Development mode:**
```bash
# Make sure Redis is running
docker-compose up -d
# Activate virtual environment
source venv/bin/activate
# Set environment
export FLASK_ENV=development
# Run development server
python wsgi.py
```
The API will be available at `http://localhost:5000`
**Production mode:**
```bash
gunicorn --bind 0.0.0.0:5000 --workers 4 wsgi:app
```
## Configuration
Environment-specific configs are in `/config`:
- `development.yaml` - Local development settings
- `production.yaml` - Production settings
Key settings:
- **Server:** Port, workers
- **Redis:** Connection settings
- **RQ:** Queue configuration
- **AI:** Model settings, rate limits
- **Auth:** Session, password requirements
- **CORS:** Allowed origins
## API Documentation
See [API_REFERENCE.md](docs/API_REFERENCE.md) for complete endpoint documentation.
### Base URL
- **Development:** `http://localhost:5000`
- **Production:** `https://api.codeofconquest.com`
### Authentication
Uses Appwrite session-based authentication with HTTP-only cookies.
### Response Format
All endpoints return standardized JSON responses:
```json
{
"app": "Code of Conquest",
"version": "0.1.0",
"status": 200,
"timestamp": "2025-01-15T10:30:00Z",
"result": {...},
"error": null
}
```
## Testing
Run tests with pytest:
```bash
# Activate virtual environment
source venv/bin/activate
# Run all tests
pytest
# Run with coverage
pytest --cov=app tests/
# Run specific test file
pytest tests/test_character.py
```
## Background Jobs
The API uses RQ for background processing:
**Start RQ worker:**
```bash
rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379
```
**Monitor jobs:**
```bash
rq info --url redis://localhost:6379
```
## Directory Structure
```
api/
├── app/ # Application code
│ ├── api/ # API endpoint blueprints
│ ├── models/ # Data models
│ ├── services/ # Business logic
│ ├── utils/ # Utilities
│ ├── tasks/ # Background jobs
│ ├── ai/ # AI integration
│ ├── game_logic/ # Game mechanics
│ └── data/ # Game data (YAML)
├── config/ # Configuration files
├── tests/ # Test suite
├── scripts/ # Utility scripts
├── logs/ # Application logs
├── docs/ # API documentation
├── requirements.txt # Python dependencies
├── wsgi.py # WSGI entry point
├── docker-compose.yml # Redis service
└── .env.example # Environment template
```
## Development
See [CLAUDE.md](../CLAUDE.md) in the project root for:
- Coding standards
- Development workflow
- Project structure guidelines
- Git conventions
## Deployment
See [DEPLOYMENT.md](../docs/DEPLOYMENT.md) for production deployment instructions.
## Related Components
- **Public Web:** `/public_web` - Traditional web frontend
- **Godot Client:** `/godot_client` - Native game client
Both frontends consume this API backend.
## License
Proprietary - All rights reserved

171
api/app/__init__.py Normal file
View 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
View 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",
]

View 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

View 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
)

View 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)

View 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

View 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

View 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.

View 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."

View 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.

View 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
View File

529
api/app/api/auth.py Normal file
View 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
View 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
)

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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: []

View 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"

View 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"

View 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"

View 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.

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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
View 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)

View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)"

View 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
View 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
View 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
View 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
View 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
View 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})"
)

View File

View 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

View 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
)

File diff suppressed because it is too large Load Diff

View 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

View 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()

View 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

View 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

View 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

View 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

View 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

View 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()

View 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

View 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

View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
"""Utility modules for Code of Conquest."""

444
api/app/utils/auth.py Normal file
View 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
View 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
View 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
)

127
api/config/development.yaml Normal file
View File

@@ -0,0 +1,127 @@
# Development Configuration for Code of Conquest
app:
name: "Code of Conquest"
version: "0.1.0"
environment: "development"
debug: true
server:
host: "0.0.0.0"
port: 5000
workers: 1
redis:
host: "localhost"
port: 6379
db: 0
max_connections: 50
rq:
queues:
- "ai_tasks"
- "combat_tasks"
- "marketplace_tasks"
worker_timeout: 600
job_timeout: 300
ai:
timeout: 30
max_retries: 3
cost_alert_threshold: 100.00
models:
free:
provider: "replicate"
model: "meta/meta-llama-3-70b-instruct"
max_tokens: 256
temperature: 0.7
standard:
provider: "anthropic"
model: "claude-3-5-haiku-20241022"
max_tokens: 512
temperature: 0.8
premium:
provider: "anthropic"
model: "claude-3-5-sonnet-20241022"
max_tokens: 1024
temperature: 0.9
rate_limiting:
enabled: true
storage_url: "redis://localhost:6379/1"
tiers:
free:
requests_per_minute: 30
ai_calls_per_day: 50
custom_actions_per_day: 10
custom_action_char_limit: 150
basic:
requests_per_minute: 60
ai_calls_per_day: 200
custom_actions_per_day: 50
custom_action_char_limit: 300
premium:
requests_per_minute: 120
ai_calls_per_day: 1000
custom_actions_per_day: -1 # Unlimited
custom_action_char_limit: 500
elite:
requests_per_minute: 300
ai_calls_per_day: -1 # Unlimited
custom_actions_per_day: -1 # Unlimited
custom_action_char_limit: 500
session:
timeout_minutes: 30
auto_save_interval: 5
min_players: 1
max_players_by_tier:
free: 1
basic: 2
premium: 6
elite: 10
auth:
# Authentication cookie settings
cookie_name: "coc_session"
duration_normal: 86400 # 24 hours (seconds)
duration_remember_me: 2592000 # 30 days (seconds)
http_only: true
secure: false # Set to true in production (HTTPS only)
same_site: "Lax"
path: "/"
# Password requirements
password_min_length: 8
password_require_uppercase: true
password_require_lowercase: true
password_require_number: true
password_require_special: true
# User input validation
name_min_length: 3
name_max_length: 50
email_max_length: 255
marketplace:
auction_check_interval: 300 # 5 minutes
max_listings_by_tier:
premium: 10
elite: 25
cors:
origins:
- "http://localhost:8000"
- "http://127.0.0.1:8000"
logging:
level: "DEBUG"
format: "json"
handlers:
- "console"
- "file"
file_path: "logs/app.log"

126
api/config/production.yaml Normal file
View File

@@ -0,0 +1,126 @@
# Production Configuration for Code of Conquest
app:
name: "Code of Conquest"
version: "0.1.0"
environment: "production"
debug: false
server:
host: "0.0.0.0"
port: 5000
workers: 4
redis:
host: "redis" # Docker service name or production host
port: 6379
db: 0
max_connections: 100
rq:
queues:
- "ai_tasks"
- "combat_tasks"
- "marketplace_tasks"
worker_timeout: 600
job_timeout: 300
ai:
timeout: 30
max_retries: 3
cost_alert_threshold: 500.00
models:
free:
provider: "replicate"
model: "meta/meta-llama-3-70b-instruct"
max_tokens: 256
temperature: 0.7
standard:
provider: "anthropic"
model: "claude-3-5-haiku-20241022"
max_tokens: 512
temperature: 0.8
premium:
provider: "anthropic"
model: "claude-3-5-sonnet-20241022"
max_tokens: 1024
temperature: 0.9
rate_limiting:
enabled: true
storage_url: "redis://redis:6379/1"
tiers:
free:
requests_per_minute: 30
ai_calls_per_day: 50
custom_actions_per_day: 10
custom_action_char_limit: 150
basic:
requests_per_minute: 60
ai_calls_per_day: 200
custom_actions_per_day: 50
custom_action_char_limit: 300
premium:
requests_per_minute: 120
ai_calls_per_day: 1000
custom_actions_per_day: -1 # Unlimited
custom_action_char_limit: 500
elite:
requests_per_minute: 300
ai_calls_per_day: -1 # Unlimited
custom_actions_per_day: -1 # Unlimited
custom_action_char_limit: 500
session:
timeout_minutes: 30
auto_save_interval: 5
min_players: 1
max_players_by_tier:
free: 1
basic: 2
premium: 6
elite: 10
auth:
# Authentication cookie settings
cookie_name: "coc_session"
duration_normal: 86400 # 24 hours (seconds)
duration_remember_me: 2592000 # 30 days (seconds)
http_only: true
secure: true # HTTPS only in production
same_site: "Lax"
path: "/"
# Password requirements
password_min_length: 8
password_require_uppercase: true
password_require_lowercase: true
password_require_number: true
password_require_special: true
# User input validation
name_min_length: 3
name_max_length: 50
email_max_length: 255
marketplace:
auction_check_interval: 300 # 5 minutes
max_listings_by_tier:
premium: 10
elite: 25
cors:
origins:
- "https://yourdomain.com" # Replace with actual production domain
logging:
level: "INFO"
format: "json"
handlers:
- "console"
- "file"
file_path: "/var/log/coc/app.log"

77
api/config/rq_config.py Normal file
View File

@@ -0,0 +1,77 @@
"""
RQ Worker Configuration
This module provides configuration settings for RQ workers.
Workers can be started with these settings using the start_workers.sh script.
Usage:
# In worker startup
from config.rq_config import WORKER_CONFIG
worker = Worker(
queues=WORKER_CONFIG['queues'],
connection=redis_conn,
**WORKER_CONFIG['worker_kwargs']
)
"""
import os
from app.tasks import ALL_QUEUES, QUEUE_AI_TASKS, QUEUE_COMBAT_TASKS, QUEUE_MARKETPLACE_TASKS
# Redis URL for workers
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# Worker configuration
WORKER_CONFIG = {
# Queues to listen on (in priority order)
'queues': ALL_QUEUES,
# Worker behavior settings
'worker_kwargs': {
'name': None, # Auto-generated if None
'default_result_ttl': 3600, # 1 hour
'default_worker_ttl': 420, # 7 minutes
'job_monitoring_interval': 5, # Check job status every 5 seconds
'disable_default_exception_handler': False,
'log_job_description': True,
},
# Burst mode (process jobs then exit)
'burst': False,
# Logging
'logging_level': os.getenv('LOG_LEVEL', 'INFO'),
}
# Scheduler configuration (for periodic tasks)
SCHEDULER_CONFIG = {
'interval': 60, # Check for scheduled jobs every 60 seconds
}
# Job retry configuration
RETRY_CONFIG = {
'max_retries': 3,
'retry_delays': [60, 300, 900], # 1 min, 5 min, 15 min
}
# Queue-specific worker settings (for specialized workers)
SPECIALIZED_WORKERS = {
'ai_worker': {
'queues': [QUEUE_AI_TASKS],
'description': 'Dedicated worker for AI generation tasks',
},
'combat_worker': {
'queues': [QUEUE_COMBAT_TASKS],
'description': 'Dedicated worker for combat processing',
},
'marketplace_worker': {
'queues': [QUEUE_MARKETPLACE_TASKS],
'description': 'Dedicated worker for marketplace tasks',
},
}

63
api/docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: coc_redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
api:
build:
context: .
dockerfile: Dockerfile
container_name: coc_api
ports:
- "5000:5000"
volumes:
- .:/app
env_file:
- .env
environment:
- FLASK_ENV=development
- REDIS_URL=redis://redis:6379/0
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
rq-worker:
build:
context: .
dockerfile: Dockerfile
container_name: coc_rq_worker
command: rq worker ai_tasks combat_tasks marketplace_tasks --url redis://redis:6379/0 --with-scheduler
volumes:
- .:/app
depends_on:
redis:
condition: service_healthy
env_file:
- .env
environment:
- REDIS_URL=redis://redis:6379/0
restart: unless-stopped
volumes:
redis_data:
driver: local

563
api/docs/ACTION_PROMPTS.md Normal file
View File

@@ -0,0 +1,563 @@
# Action Prompts System
## Overview
Action prompts are predefined story actions that players can select during gameplay. They appear as buttons in the story UI, with availability filtered by subscription tier and location type.
**Key Features:**
- YAML-driven configuration
- Tier-based availability (Free, Basic, Premium, Elite)
- Location-based context filtering
- Custom AI prompt templates per action
**Files:**
- **Model:** `app/models/action_prompt.py`
- **Loader:** `app/services/action_prompt_loader.py`
- **Data:** `app/data/action_prompts.yaml`
---
## Architecture
```
┌─────────────────────────┐
│ action_prompts.yaml │ ← YAML configuration
├─────────────────────────┤
│ ActionPromptLoader │ ← Singleton loader/cache
├─────────────────────────┤
│ ActionPrompt │ ← Data model
├─────────────────────────┤
│ Story UI / API │ ← Filtered action buttons
└─────────────────────────┘
```
---
## ActionPrompt Model
**File:** `app/models/action_prompt.py`
### Fields
```python
@dataclass
class ActionPrompt:
prompt_id: str # Unique identifier
category: ActionCategory # Action category
display_text: str # Button label
description: str # Tooltip text
tier_required: UserTier # Minimum tier
context_filter: List[LocationType] # Where available
dm_prompt_template: str # AI prompt template
icon: Optional[str] = None # Optional icon name
cooldown_turns: int = 0 # Turns before reuse
```
### ActionCategory Enum
```python
class ActionCategory(str, Enum):
ASK_QUESTION = "ask_question" # Gather info from NPCs
TRAVEL = "travel" # Move to new location
GATHER_INFO = "gather_info" # Search/investigate
REST = "rest" # Rest and recover
INTERACT = "interact" # Interact with objects
EXPLORE = "explore" # Explore the area
SPECIAL = "special" # Tier-specific special actions
```
### LocationType Enum
```python
class LocationType(str, Enum):
TOWN = "town" # Populated settlements
TAVERN = "tavern" # Taverns and inns
WILDERNESS = "wilderness" # Outdoor areas
DUNGEON = "dungeon" # Dungeons and caves
SAFE_AREA = "safe_area" # Protected zones
LIBRARY = "library" # Libraries and archives
ANY = "any" # Available everywhere
```
### Availability Methods
```python
from app.models.action_prompt import ActionPrompt, LocationType
from app.ai.model_selector import UserTier
# Check if available
if action.is_available(UserTier.FREE, LocationType.TOWN):
# Show action to player
pass
# Check if locked (tier too low)
if action.is_locked(UserTier.FREE):
reason = action.get_lock_reason(UserTier.FREE)
# "Requires Premium tier or higher"
```
### Serialization
```python
# To dictionary
data = action.to_dict()
# From dictionary
action = ActionPrompt.from_dict(data)
```
---
## ActionPromptLoader Service
**File:** `app/services/action_prompt_loader.py`
Singleton service that loads and caches action prompts from YAML.
### Basic Usage
```python
from app.services.action_prompt_loader import ActionPromptLoader
from app.models.action_prompt import LocationType
from app.ai.model_selector import UserTier
loader = ActionPromptLoader()
# Load from YAML (or auto-loads from default path)
loader.load_from_yaml("app/data/action_prompts.yaml")
# Get available actions for user at location
actions = loader.get_available_actions(
user_tier=UserTier.FREE,
location_type=LocationType.TOWN
)
for action in actions:
print(f"{action.display_text} - {action.description}")
```
### Query Methods
```python
# Get specific action
action = loader.get_action_by_id("ask_locals")
# Get all actions
all_actions = loader.get_all_actions()
# Get actions by tier (ignoring location)
tier_actions = loader.get_actions_by_tier(UserTier.PREMIUM)
# Get actions by category
questions = loader.get_actions_by_category("ask_question")
# Get locked actions (for upgrade prompts)
locked = loader.get_locked_actions(UserTier.FREE, LocationType.TOWN)
```
### Utility Methods
```python
# Check if loaded
if loader.is_loaded():
count = loader.get_prompt_count()
# Force reload
loader.reload("app/data/action_prompts.yaml")
# Reset singleton (for testing)
ActionPromptLoader.reset_instance()
```
### Error Handling
```python
from app.services.action_prompt_loader import (
ActionPromptLoader,
ActionPromptLoaderError,
ActionPromptNotFoundError
)
try:
action = loader.get_action_by_id("invalid_id")
except ActionPromptNotFoundError:
# Action not found
pass
try:
loader.load_from_yaml("invalid_path.yaml")
except ActionPromptLoaderError as e:
# File not found, invalid YAML, etc.
pass
```
---
## YAML Configuration
**File:** `app/data/action_prompts.yaml`
### Structure
```yaml
action_prompts:
- prompt_id: unique_id
category: ask_question
display_text: Button Label
description: Tooltip description
tier_required: free
context_filter: [town, tavern]
icon: chat
cooldown_turns: 0
dm_prompt_template: |
Jinja2 template for AI prompt...
```
### Available Actions
#### Free Tier (4 actions)
| ID | Display Text | Category | Locations |
|----|-------------|----------|-----------|
| `ask_locals` | Ask locals for information | ask_question | town, tavern |
| `explore_area` | Explore the area | explore | wilderness, dungeon |
| `search_supplies` | Search for supplies | gather_info | any |
| `rest_recover` | Rest and recover | rest | town, tavern, safe_area |
#### Premium Tier (+3 actions)
| ID | Display Text | Category | Locations |
|----|-------------|----------|-----------|
| `investigate_suspicious` | Investigate suspicious activity | gather_info | any |
| `follow_lead` | Follow a lead | travel | any |
| `make_camp` | Make camp | rest | wilderness |
#### Elite Tier (+3 actions)
| ID | Display Text | Category | Locations |
|----|-------------|----------|-----------|
| `consult_texts` | Consult ancient texts | special | library, town |
| `commune_nature` | Commune with nature | special | wilderness |
| `seek_audience` | Seek audience with authorities | special | town |
### Total by Tier
- **Free:** 4 actions
- **Premium:** 7 actions (Free + 3)
- **Elite:** 10 actions (Premium + 3)
---
## DM Prompt Templates
Each action has a Jinja2 template for generating AI prompts.
### Available Variables
```jinja2
{{ character.name }}
{{ character.level }}
{{ character.player_class }}
{{ character.current_hp }}
{{ character.max_hp }}
{{ character.stats.strength }}
{{ character.stats.dexterity }}
{{ character.stats.constitution }}
{{ character.stats.intelligence }}
{{ character.stats.wisdom }}
{{ character.stats.charisma }}
{{ character.reputation }}
{{ game_state.current_location }}
{{ game_state.location_type }}
{{ game_state.active_quests }}
```
### Template Example
```yaml
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.
End with options for where to go or what to investigate next.
```
### How Templates Flow to AI
The `dm_prompt_template` is passed through the system as follows:
1. **Sessions API** loads the action prompt and extracts `dm_prompt_template`
2. **AI task** receives it in the context as `dm_prompt_template`
3. **NarrativeGenerator** receives it as `action_instructions` parameter
4. **story_action.j2** injects it under "Action-Specific Instructions"
```python
# In ai_tasks.py
response = generator.generate_story_response(
character=context['character'],
action=context['action'],
game_state=context['game_state'],
user_tier=user_tier,
action_instructions=context.get('dm_prompt_template') # From action prompt
)
```
### Player Agency Rules
All action templates should follow these critical rules to maintain player agency:
```yaml
# Example with player agency enforcement
dm_prompt_template: |
The player searches for supplies in {{ game_state.current_location }}.
IMPORTANT - This is a SEARCH action, not a purchase action:
- In towns/markets: Describe vendors and wares with PRICES. Ask what to buy.
- In wilderness: Describe what they FIND. Ask if they want to gather.
NEVER automatically:
- Purchase items or spend gold
- Add items to inventory without asking
- Complete any transaction
End with: "What would you like to do?"
```
The base `story_action.j2` template also enforces these rules globally:
- Never make decisions for the player
- Never complete transactions without consent
- Present choices and let the player decide
---
## Integration Examples
### API Endpoint
```python
@bp.route('/sessions/<session_id>/available-actions', methods=['GET'])
@require_auth
def get_available_actions(session_id):
user = get_current_user()
session = get_session(session_id)
loader = ActionPromptLoader()
# Get available actions
available = loader.get_available_actions(
user_tier=user.tier,
location_type=session.game_state.location_type
)
# Get locked actions for upgrade prompts
locked = loader.get_locked_actions(
user_tier=user.tier,
location_type=session.game_state.location_type
)
return api_response(
status=200,
result={
"available_actions": [a.to_dict() for a in available],
"locked_actions": [
{
**a.to_dict(),
"lock_reason": a.get_lock_reason(user.tier)
}
for a in locked
]
}
)
```
### Story UI Template
```jinja2
{% for action in available_actions %}
<button
class="action-btn"
hx-post="/api/v1/sessions/{{ session_id }}/action"
hx-vals='{"action_type": "button", "prompt_id": "{{ action.prompt_id }}"}'
hx-target="#dm-response"
title="{{ action.description }}">
{% if action.icon %}<i class="icon-{{ action.icon }}"></i>{% endif %}
{{ action.display_text }}
</button>
{% endfor %}
{% for action in locked_actions %}
<button
class="action-btn locked"
disabled
title="{{ action.lock_reason }}">
{{ action.display_text }}
<span class="lock-icon">🔒</span>
</button>
{% endfor %}
```
### Processing Action in AI Task
```python
from app.services.action_prompt_loader import ActionPromptLoader
from app.ai.prompt_templates import render_prompt
def process_button_action(session_id: str, prompt_id: str, user_tier: UserTier):
loader = ActionPromptLoader()
session = get_session(session_id)
character = get_character(session.character_id)
# Get the action
action = loader.get_action_by_id(prompt_id)
# Verify availability
if not action.is_available(user_tier, session.game_state.location_type):
raise ValueError("Action not available")
# Build the AI prompt using action's template
prompt = render_prompt(
action.dm_prompt_template,
character=character.to_dict(),
game_state=session.game_state.to_dict()
)
# Generate AI response
response = narrative_generator.generate_story_response(...)
return response
```
---
## Adding New Actions
### 1. Add to YAML
```yaml
- prompt_id: new_action_id
category: explore # Must match ActionCategory enum
display_text: My New Action
description: What this action does
tier_required: premium # free, basic, premium, elite
context_filter: [town, wilderness] # Or [any] for all
icon: star # Optional icon name
cooldown_turns: 2 # 0 for no cooldown
dm_prompt_template: |
The player {{ action description }}...
Character: {{ character.name }}, Level {{ character.level }}
Describe:
- What happens
- What they discover
- Next steps
End with a clear outcome.
```
### 2. Reload Actions
```python
loader = ActionPromptLoader()
loader.reload("app/data/action_prompts.yaml")
```
### 3. Test
```python
action = loader.get_action_by_id("new_action_id")
assert action.is_available(UserTier.PREMIUM, LocationType.TOWN)
```
---
## Tier Hierarchy
Actions are available to users at or above the required tier:
```
FREE (0) < BASIC (1) < PREMIUM (2) < ELITE (3)
```
- **FREE action:** Available to all tiers
- **PREMIUM action:** Available to Premium and Elite
- **ELITE action:** Available only to Elite
---
## Cooldown System
Actions with `cooldown_turns > 0` cannot be used again for that many turns.
```yaml
cooldown_turns: 3 # Cannot use for 3 turns after use
```
Cooldown tracking should be implemented in the session/game state.
---
## Testing
### Unit Tests
```python
def test_action_availability():
action = ActionPrompt(
prompt_id="test",
category=ActionCategory.EXPLORE,
display_text="Test",
description="Test action",
tier_required=UserTier.PREMIUM,
context_filter=[LocationType.TOWN],
dm_prompt_template="Test"
)
# Premium action available to Elite in Town
assert action.is_available(UserTier.ELITE, LocationType.TOWN) == True
# Premium action not available to Free
assert action.is_available(UserTier.FREE, LocationType.TOWN) == False
# Not available in wrong location
assert action.is_available(UserTier.ELITE, LocationType.WILDERNESS) == False
def test_loader():
loader = ActionPromptLoader()
loader.load_from_yaml("app/data/action_prompts.yaml")
# Free tier in town should see limited actions
actions = loader.get_available_actions(UserTier.FREE, LocationType.TOWN)
assert len(actions) == 2 # ask_locals, search_supplies
# Elite tier sees all actions for location
elite_actions = loader.get_available_actions(UserTier.ELITE, LocationType.TOWN)
assert len(elite_actions) > len(actions)
```
### Manual Verification
```python
from app.services.action_prompt_loader import ActionPromptLoader
from app.models.action_prompt import LocationType
from app.ai.model_selector import UserTier
loader = ActionPromptLoader()
loader.load_from_yaml("app/data/action_prompts.yaml")
print(f"Total actions: {loader.get_prompt_count()}")
for tier in [UserTier.FREE, UserTier.PREMIUM, UserTier.ELITE]:
actions = loader.get_actions_by_tier(tier)
print(f"{tier.value}: {len(actions)} actions")
```

538
api/docs/AI_INTEGRATION.md Normal file
View File

@@ -0,0 +1,538 @@
# AI Integration Documentation
## Overview
Code of Conquest uses AI models for narrative generation through a unified Replicate API integration. This document covers the AI client architecture, model selection, and usage patterns.
**Key Components:**
- **ReplicateClient** - Low-level API client for all AI models
- **ModelSelector** - Tier-based model routing and configuration
- **NarrativeGenerator** - High-level wrapper for game-specific generation
---
## Architecture
```
┌─────────────────────┐
│ NarrativeGenerator │ ← High-level game API
├─────────────────────┤
│ ModelSelector │ ← Tier/context routing
├─────────────────────┤
│ ReplicateClient │ ← Unified API client
├─────────────────────┤
│ Replicate API │ ← All models (Llama, Claude)
└─────────────────────┘
```
All AI models are accessed through Replicate API for unified billing and management.
---
## Replicate Client
**File:** `app/ai/replicate_client.py`
### Supported Models
| Model Type | Identifier | Tier | Use Case |
|------------|-----------|------|----------|
| `LLAMA_3_8B` | `meta/meta-llama-3-8b-instruct` | Free | Cost-effective, good quality |
| `CLAUDE_HAIKU` | `anthropic/claude-3.5-haiku` | Basic | Fast, high quality |
| `CLAUDE_SONNET` | `anthropic/claude-3.5-sonnet` | Premium | Excellent quality |
| `CLAUDE_SONNET_4` | `anthropic/claude-4.5-sonnet` | Elite | Best quality |
### Basic Usage
```python
from app.ai.replicate_client import ReplicateClient, ModelType
# Free tier - Llama (default)
client = ReplicateClient()
response = client.generate(
prompt="You are a dungeon master...",
max_tokens=256,
temperature=0.7
)
print(response.text)
print(f"Tokens: {response.tokens_used}")
# Paid tier - Claude models
client = ReplicateClient(model=ModelType.CLAUDE_HAIKU)
response = client.generate(
prompt="Describe the tavern",
system_prompt="You are a dungeon master"
)
# Override model per-call
response = client.generate("Test", model=ModelType.CLAUDE_SONNET)
```
### Response Object
```python
@dataclass
class ReplicateResponse:
text: str # Generated text
tokens_used: int # Approximate token count
model: str # Model identifier
generation_time: float # Generation time in seconds
```
### Configuration
```python
# Default 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},
}
```
### Error Handling
```python
from app.ai.replicate_client import (
ReplicateClientError, # Base error
ReplicateAPIError, # API errors
ReplicateRateLimitError, # Rate limiting
ReplicateTimeoutError # Timeouts
)
try:
response = client.generate(prompt)
except ReplicateRateLimitError:
# Handle rate limiting (client retries automatically 3 times)
pass
except ReplicateTimeoutError:
# Handle timeout
pass
except ReplicateAPIError as e:
# Handle other API errors
logger.error(f"API error: {e}")
```
### Features
- **Retry Logic**: Exponential backoff (3 retries) for rate limits
- **Model-specific Formatting**: Llama special tokens, Claude system prompts
- **API Key Validation**: `client.validate_api_key()` method
---
## Model Selector
**File:** `app/ai/model_selector.py`
### User Tiers
```python
class UserTier(str, Enum):
FREE = "free" # Llama 3 8B
BASIC = "basic" # Claude Haiku
PREMIUM = "premium" # Claude Sonnet
ELITE = "elite" # Claude Sonnet 4
```
### Context Types
```python
class ContextType(str, Enum):
STORY_PROGRESSION = "story_progression" # Creative narratives
COMBAT_NARRATION = "combat_narration" # Action descriptions
QUEST_SELECTION = "quest_selection" # Quest picking
NPC_DIALOGUE = "npc_dialogue" # Character conversations
SIMPLE_RESPONSE = "simple_response" # Quick responses
```
### Usage
```python
from app.ai.model_selector import ModelSelector, UserTier, ContextType
selector = ModelSelector()
# Select model configuration
config = selector.select_model(
user_tier=UserTier.PREMIUM,
context_type=ContextType.STORY_PROGRESSION
)
print(config.model_type) # ModelType.CLAUDE_SONNET
print(config.max_tokens) # 1024
print(config.temperature) # 0.9
```
### Token Limits by Tier
| Tier | Base Tokens | Model |
|------|-------------|-------|
| FREE | 256 | Llama 3 8B |
| BASIC | 512 | Claude Haiku |
| PREMIUM | 1024 | Claude Sonnet |
| ELITE | 2048 | Claude Sonnet 4 |
### Context Adjustments
**Temperature by Context:**
- Story Progression: 0.9 (creative)
- Combat Narration: 0.8 (exciting)
- Quest Selection: 0.5 (deterministic)
- NPC Dialogue: 0.85 (natural)
- Simple Response: 0.7 (balanced)
**Token Multipliers:**
- Story Progression: 1.0× (full allocation)
- Combat Narration: 0.75× (shorter)
- Quest Selection: 0.5× (brief)
- NPC Dialogue: 0.75× (conversational)
- Simple Response: 0.5× (quick)
### Cost Estimation
```python
# Get tier information
info = selector.get_tier_info(UserTier.PREMIUM)
# {
# "tier": "premium",
# "model": "anthropic/claude-3.5-sonnet",
# "model_name": "Claude 3.5 Sonnet",
# "base_tokens": 1024,
# "quality": "Excellent quality, detailed narratives"
# }
# Estimate cost per request
cost = selector.estimate_cost_per_request(UserTier.PREMIUM)
# ~$0.009 per request
```
---
## Narrative Generator
**File:** `app/ai/narrative_generator.py`
High-level wrapper that coordinates model selection, prompt templates, and AI generation.
### Initialization
```python
from app.ai.narrative_generator import NarrativeGenerator
from app.ai.model_selector import UserTier
generator = NarrativeGenerator()
```
### Story Response Generation
```python
response = generator.generate_story_response(
character={
"name": "Aldric",
"level": 3,
"player_class": "Fighter",
"stats": {"strength": 16, "dexterity": 14, ...}
},
action="I search the room for hidden doors",
game_state={
"current_location": "Ancient Library",
"location_type": "DUNGEON",
"active_quests": ["find_artifact"]
},
user_tier=UserTier.PREMIUM,
conversation_history=[
{"turn": 1, "action": "entered library", "dm_response": "..."},
{"turn": 2, "action": "examined shelves", "dm_response": "..."}
],
action_instructions="""
The player searches for supplies. This means:
- Describe what they FIND, not auto-purchase
- List items with PRICES if applicable
- Ask what they want to do with findings
""" # Optional: from action_prompts.yaml dm_prompt_template
)
print(response.narrative)
print(f"Tokens: {response.tokens_used}")
print(f"Model: {response.model}")
print(f"Time: {response.generation_time:.2f}s")
```
### Action Instructions
The `action_instructions` parameter passes action-specific guidance from `action_prompts.yaml` to the AI. This ensures:
1. **Player agency** - AI presents options rather than making decisions
2. **Action semantics** - "Search" means find options, not auto-buy
3. **Context-aware responses** - Different instructions for different actions
The instructions are injected into the prompt template and include critical player agency rules:
- Never auto-purchase items
- Never complete transactions without consent
- Present choices and ask what they want to do
### Combat Narration
```python
response = generator.generate_combat_narration(
character={"name": "Aldric", ...},
combat_state={
"round_number": 3,
"enemies": [{"name": "Goblin", "hp": 5, "max_hp": 10}],
"terrain": "cave"
},
action="swings their sword at the goblin",
action_result={
"hit": True,
"damage": 12,
"effects": ["bleeding"]
},
user_tier=UserTier.BASIC,
is_critical=True,
is_finishing_blow=True
)
```
### Quest Selection
```python
quest_id = generator.generate_quest_selection(
character={"name": "Aldric", "level": 3, ...},
eligible_quests=[
{"quest_id": "goblin_cave", "name": "Clear the Cave", ...},
{"quest_id": "herb_gathering", "name": "Gather Herbs", ...}
],
game_context={
"current_location": "Tavern",
"recent_events": ["talked to locals"]
},
user_tier=UserTier.FREE,
recent_actions=["asked about rumors", "ordered ale"]
)
print(quest_id) # "goblin_cave"
```
### NPC Dialogue
```python
response = generator.generate_npc_dialogue(
character={"name": "Aldric", ...},
npc={
"name": "Old Barkeep",
"role": "Tavern Owner",
"personality": "gruff but kind"
},
conversation_topic="What rumors have you heard lately?",
game_state={"current_location": "The Rusty Anchor", ...},
user_tier=UserTier.PREMIUM,
npc_knowledge=["goblin attacks", "missing merchant"]
)
```
### Response Object
```python
@dataclass
class NarrativeResponse:
narrative: str # Generated text
tokens_used: int # Token count
model: str # Model used
context_type: str # Type of generation
generation_time: float
```
### Error Handling
```python
from app.ai.narrative_generator import NarrativeGeneratorError
try:
response = generator.generate_story_response(...)
except NarrativeGeneratorError as e:
logger.error(f"Generation failed: {e}")
# Handle gracefully (show error to user, use fallback, etc.)
```
---
## Prompt Templates
**File:** `app/ai/prompt_templates.py`
**Templates:** `app/ai/templates/*.j2`
### Available Templates
1. **story_action.j2** - Story progression turns
2. **combat_action.j2** - Combat narration
3. **quest_offering.j2** - Context-aware quest selection
4. **npc_dialogue.j2** - NPC conversations
### Template Filters
- `format_inventory` - Format item lists
- `format_stats` - Format character stats
- `format_skills` - Format skill lists
- `format_effects` - Format active effects
- `truncate_text` - Truncate with ellipsis
- `format_gold` - Format currency
### Direct Template Usage
```python
from app.ai.prompt_templates import get_prompt_templates
templates = get_prompt_templates()
prompt = templates.render(
"story_action.j2",
character={"name": "Aldric", ...},
action="search for traps",
game_state={...},
conversation_history=[...]
)
```
---
## Configuration
### Environment Variables
```bash
# Required
REPLICATE_API_TOKEN=r8_...
# Optional (defaults shown)
REPLICATE_MODEL=meta/meta-llama-3-8b-instruct
```
### Cost Management
Approximate costs per 1K tokens:
| Model | Input | Output |
|-------|-------|--------|
| Llama 3 8B | Free | Free |
| Claude Haiku | $0.001 | $0.005 |
| Claude Sonnet | $0.003 | $0.015 |
| Claude Sonnet 4 | $0.015 | $0.075 |
---
## Integration with Background Jobs
AI generation runs asynchronously via RQ jobs. See `app/tasks/ai_tasks.py`.
```python
from app.tasks.ai_tasks import enqueue_ai_task
# Queue a story action
job = enqueue_ai_task(
task_type="narrative",
user_id="user_123",
context={
"session_id": "sess_789",
"character_id": "char_456",
"action": "I explore the tavern"
}
)
# Returns: {"job_id": "abc-123", "status": "queued"}
```
---
## Usage Tracking
All AI calls are automatically logged for cost monitoring. See `app/services/usage_tracking_service.py`.
```python
from app.services.usage_tracking_service import UsageTrackingService
tracker = UsageTrackingService()
# Get daily usage
usage = tracker.get_daily_usage("user_123", date.today())
print(f"Requests: {usage.total_requests}")
print(f"Cost: ${usage.estimated_cost:.4f}")
# Get monthly cost
monthly = tracker.get_monthly_cost("user_123", 2025, 11)
```
---
## Rate Limiting
Tier-based daily limits enforced via `app/services/rate_limiter_service.py`.
### AI Calls (Turns)
| Tier | Daily Limit |
|------|------------|
| FREE | 20 turns |
| BASIC | 50 turns |
| PREMIUM | 100 turns |
| ELITE | 200 turns |
### Custom Actions
Free-text player actions (beyond preset buttons) have separate limits:
| Tier | Custom Actions/Day | Max Characters |
|------|-------------------|----------------|
| FREE | 10 | 150 |
| BASIC | 50 | 300 |
| PREMIUM | Unlimited | 500 |
| ELITE | Unlimited | 500 |
These are configurable in `config/*.yaml` under `rate_limiting.tiers.{tier}.custom_actions_per_day` and `custom_action_char_limit`.
```python
from app.services.rate_limiter_service import RateLimiterService
limiter = RateLimiterService()
try:
limiter.check_rate_limit("user_123", UserTier.PREMIUM)
# Process request...
limiter.increment_usage("user_123")
except RateLimitExceeded as e:
# Return error to user
pass
```
---
## Best Practices
1. **Always specify context type** - Helps optimize token usage and temperature
2. **Provide conversation history** - Improves narrative coherence
3. **Handle errors gracefully** - Show user-friendly messages
4. **Monitor costs** - Use usage tracking service
5. **Test with mocks first** - Use mocked clients during development
---
## Verification Scripts
- `scripts/verify_ai_models.py` - Test model routing and API connectivity
- `scripts/verify_e2e_ai_generation.py` - End-to-end generation flow tests
```bash
# Test model routing (no API key needed)
python scripts/verify_ai_models.py
# Test with real API calls
python scripts/verify_ai_models.py --llama --haiku --sonnet
# Full E2E test
python scripts/verify_e2e_ai_generation.py --real --tier premium
```

2309
api/docs/API_REFERENCE.md Normal file

File diff suppressed because it is too large Load Diff

1864
api/docs/API_TESTING.md Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More